chromium/ios/chrome/browser/bubble/ui_bundled/bubble_presenter.mm

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/bubble/ui_bundled/bubble_presenter.h"

#import "base/apple/foundation_util.h"
#import "base/functional/bind.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "components/content_settings/core/browser/host_content_settings_map.h"
#import "components/feature_engagement/public/event_constants.h"
#import "components/feature_engagement/public/feature_constants.h"
#import "components/feature_engagement/public/tracker.h"
#import "components/omnibox/browser/omnibox_event_global_tracker.h"
#import "components/prefs/pref_service.h"
#import "components/segmentation_platform/embedder/default_model/device_switcher_result_dispatcher.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_constants.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_presenter_delegate.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_util.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view_controller_presenter.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view_delegate.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/toolbar_swipe_gesture_in_product_help_view.h"
#import "ios/chrome/browser/feature_engagement/model/tracker_factory.h"
#import "ios/chrome/browser/iph_for_new_chrome_user/model/utils.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_recorder.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter_observer_bridge.h"
#import "ios/chrome/browser/segmentation_platform/model/segmentation_platform_service_factory.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/model/url/url_util.h"
#import "ios/chrome/browser/shared/model/utils/first_run_util.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/tab_strip_commands.h"
#import "ios/chrome/browser/shared/public/commands/toolbar_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/browser/shared/ui/elements/custom_highlight_button.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.h"
#import "ios/chrome/browser/shared/ui/util/named_guide.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/shared/ui/util/util_swift.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "ios/web/public/ui/crw_web_view_proxy.h"
#import "ios/web/public/ui/crw_web_view_scroll_view_proxy.h"
#import "ios/web/public/web_state.h"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

// Returns whether `view` could display and animate correctly within `guide`. If
// NO, elements in `view` may be hidden or overlap with each other during the
// animation.
BOOL CanGestureInProductHelpViewFitInGuide(GestureInProductHelpView* view,
                                           UILayoutGuide* guide) {
  CGSize guide_size = guide.layoutFrame.size;
  CGSize view_fitting_size =
      [view systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
  return view_fitting_size.width <= guide_size.width &&
         view_fitting_size.height <= guide_size.height;
}

}  // namespace

@interface BubblePresenter () <GestureInProductHelpViewDelegate,
                               OverlayPresenterObserving>

@end

@implementation BubblePresenter {
  // Required dependencies.
  LayoutGuideCenter* _layoutGuideCenter;
  raw_ptr<WebStateList> _webStateList;
  raw_ptr<feature_engagement::Tracker> _engagementTracker;

  // Overlay observing.
  raw_ptr<OverlayPresenter> _webContentOverlayPresenter;
  raw_ptr<OverlayPresenter> _infobarBannerPresenter;
  raw_ptr<OverlayPresenter> _infobarModalPresenter;
  std::unique_ptr<OverlayPresenterObserver> _overlayPresenterObserver;

  // Whether the presenter is started.
  BOOL _started;

  // List of existing bubble view presenters.
  BubbleViewControllerPresenter* _bottomToolbarTipBubblePresenter;
  BubbleViewControllerPresenter* _openNewTabIPHBubblePresenter;
  BubbleViewControllerPresenter* _sharePageIPHBubblePresenter;
  BubbleViewControllerPresenter* _tabGridIPHBubblePresenter;
  BubbleViewControllerPresenter* _discoverFeedHeaderMenuTipBubblePresenter;
  BubbleViewControllerPresenter* _homeCustomizationMenuTipBubblePresenter;
  BubbleViewControllerPresenter* _readingListTipBubblePresenter;
  BubbleViewControllerPresenter* _followWhileBrowsingBubbleTipPresenter;
  BubbleViewControllerPresenter* _defaultPageModeTipBubblePresenter;
  BubbleViewControllerPresenter* _whatsNewBubblePresenter;
  BubbleViewControllerPresenter*
      _priceNotificationsWhileBrowsingBubbleTipPresenter;
  BubbleViewControllerPresenter* _lensKeyboardPresenter;
  BubbleViewControllerPresenter* _parcelTrackingTipBubblePresenter;

  // List of existing gestural IPH views.
  GestureInProductHelpView* _pullToRefreshGestureIPH;
  GestureInProductHelpView* _swipeBackForwardGestureIPH;
  ToolbarSwipeGestureInProductHelpView* _toolbarSwipeGestureIPH;
}

- (instancetype)
        initWithLayoutGuideCenter:(LayoutGuideCenter*)layoutGuideCenter
                engagementTracker:
                    (raw_ptr<feature_engagement::Tracker>)engagementTracker
                     webStateList:(raw_ptr<WebStateList>)webStateList
    overlayPresenterForWebContent:
        (raw_ptr<OverlayPresenter>)webContentOverlayPresenter
                    infobarBanner:(raw_ptr<OverlayPresenter>)bannerPresenter
                     infobarModal:(raw_ptr<OverlayPresenter>)modalPresenter {
  self = [super init];
  if (self) {
    CHECK(webStateList);

    _layoutGuideCenter = layoutGuideCenter;
    _engagementTracker = engagementTracker;
    _webStateList = webStateList;

    _overlayPresenterObserver =
        std::make_unique<OverlayPresenterObserverBridge>(self);

    // Set and observe overlay presenters.
    if (webContentOverlayPresenter) {
      CHECK(webContentOverlayPresenter->GetModality() ==
            OverlayModality::kWebContentArea);
      _webContentOverlayPresenter = webContentOverlayPresenter;
      _webContentOverlayPresenter->AddObserver(_overlayPresenterObserver.get());
    }
    if (bannerPresenter) {
      CHECK(bannerPresenter->GetModality() == OverlayModality::kInfobarBanner);
      _infobarBannerPresenter = bannerPresenter;
      _infobarBannerPresenter->AddObserver(_overlayPresenterObserver.get());
    }
    if (modalPresenter) {
      CHECK(modalPresenter->GetModality() == OverlayModality::kInfobarModal);
      _infobarModalPresenter = modalPresenter;
      _infobarModalPresenter->AddObserver(_overlayPresenterObserver.get());
    }

    _started = YES;
  }
  return self;
}

- (void)disconnect {
  _started = NO;
  [self disconnectOverlayPresenters];
  _webStateList = nullptr;
  _engagementTracker = nullptr;
}

- (void)hideAllHelpBubbles {
  [_sharePageIPHBubblePresenter dismissAnimated:NO];
  [_openNewTabIPHBubblePresenter dismissAnimated:NO];
  [_tabGridIPHBubblePresenter dismissAnimated:NO];
  [_bottomToolbarTipBubblePresenter dismissAnimated:NO];
  [_discoverFeedHeaderMenuTipBubblePresenter dismissAnimated:NO];
  [_homeCustomizationMenuTipBubblePresenter dismissAnimated:NO];
  [_readingListTipBubblePresenter dismissAnimated:NO];
  [_followWhileBrowsingBubbleTipPresenter dismissAnimated:NO];
  [_priceNotificationsWhileBrowsingBubbleTipPresenter dismissAnimated:NO];
  [_whatsNewBubblePresenter dismissAnimated:NO];
  [_lensKeyboardPresenter dismissAnimated:NO];
  [_defaultPageModeTipBubblePresenter dismissAnimated:NO];
  [_parcelTrackingTipBubblePresenter dismissAnimated:NO];
  [self hideAllGestureInProductHelpViewsForReason:IPHDismissalReasonType::
                                                      kUnknown];
}

- (void)handleTapOutsideOfVisibleGestureInProductHelp {
  [self hideAllGestureInProductHelpViewsForReason:
            IPHDismissalReasonType::kTappedOutsideIPHAndAnchorView];
}

- (void)presentShareButtonHelpBubbleWithDeviceSwitcherResultDispatcher:
    (raw_ptr<segmentation_platform::DeviceSwitcherResultDispatcher>)
        deviceSwitcherResultDispatcher {
  if (!deviceSwitcherResultDispatcher ||
      !iph_for_new_chrome_user::IsUserNewSafariSwitcher(
          deviceSwitcherResultDispatcher)) {
    return;
  }

  UIView* shareButtonView =
      [_layoutGuideCenter referencedViewUnderName:kShareButtonGuide];
  // Do not present if the share button is not visible.
  if (!shareButtonView || shareButtonView.hidden) {
    return;
  }

  // DCHECK if the type is not `CustomHighlightableButton`.
  __weak CustomHighlightableButton* shareButton =
      base::apple::ObjCCastStrict<CustomHighlightableButton>(shareButtonView);

  // Do not present if button is disabled.
  if (![shareButton isEnabled]) {
    return;
  }

  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BOOL isBottomOmnibox = IsBottomOmniboxAvailable() &&
                         GetApplicationContext()->GetLocalState()->GetBoolean(
                             prefs::kBottomOmnibox);
  BubbleArrowDirection arrowDirection =
      isBottomOmnibox ? BubbleArrowDirectionDown : BubbleArrowDirectionUp;
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_SHARE_THIS_PAGE_IPH_TEXT);
  NSString* announcement =
      l10n_util::GetNSString(IDS_IOS_SHARE_THIS_PAGE_IPH_ANNOUNCEMENT);
  CGPoint shareButtonAnchor = [self anchorPointToGuide:kShareButtonGuide
                                             direction:arrowDirection];

  auto presentAction = ^() {
    [shareButton setCustomHighlighted:YES];
  };
  auto dismissAction = ^() {
    [shareButton setCustomHighlighted:NO];
  };

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSShareToolbarItemFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentBottomOrTrailing
                         text:text
        voiceOverAnnouncement:announcement
                  anchorPoint:shareButtonAnchor
                presentAction:presentAction
                dismissAction:dismissAction];
  if (!presenter) {
    return;
  }

  _sharePageIPHBubblePresenter = presenter;
}

- (void)presentDiscoverFeedMenuTipBubble {
  BubbleArrowDirection arrowDirection = IsHomeCustomizationEnabled()
                                            ? BubbleArrowDirectionUp
                                            : BubbleArrowDirectionDown;
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_DISCOVER_FEED_HEADER_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // Checks "canPresentBubble" after checking that the NTP with feed is visible.
  // This ensures that the feature tracker doesn't trigger the IPH event if the
  // bubble isn't shown, which would prevent it from ever being shown again.
  if (!menuButton || ![self canPresentBubble]) {
    return;
  }
  CGPoint discoverFeedMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  if (IsHomeCustomizationEnabled()) {
    discoverFeedMenuAnchor.x += menuButton.frame.size.width / 2;
    discoverFeedMenuAnchor.y += menuButton.frame.size.height;
  } else {
    discoverFeedMenuAnchor.x += menuButton.frame.size.width / 3;
  }

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `discoverFeedHeaderMenuTipBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHDiscoverFeedHeaderFeature
                    direction:arrowDirection
                    alignment:IsHomeCustomizationEnabled()
                                  ? BubbleAlignmentTopOrLeading
                                  : BubbleAlignmentBottomOrTrailing
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:discoverFeedMenuAnchor
                presentAction:nil
                dismissAction:nil];
  if (!presenter)
    return;

  _discoverFeedHeaderMenuTipBubblePresenter = presenter;
}

- (void)presentHomeCustomizationTipBubble {
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_HOME_CUSTOMIZATION_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // Checks "canPresentBubble" after checking that the NTP with feed is visible.
  // This ensures that the feature tracker doesn't trigger the IPH event if the
  // bubble isn't shown, which would prevent it from ever being shown again.
  if (!menuButton || ![self canPresentBubble]) {
    return;
  }
  CGPoint customizationMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  customizationMenuAnchor.x += menuButton.frame.size.width / 2;
  customizationMenuAnchor.y += menuButton.frame.size.height;

  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::
                                        kIPHHomeCustomizationMenuFeature
                          direction:BubbleArrowDirectionUp
                          alignment:BubbleAlignmentTopOrLeading
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:customizationMenuAnchor
                      presentAction:nil
                      dismissAction:nil];
  if (!presenter) {
    return;
  }

  _homeCustomizationMenuTipBubblePresenter = presenter;
}

- (void)presentFollowWhileBrowsingTipBubbleAndLogWithRecorder:
    (FeedMetricsRecorder*)recorder {
  if (![self canPresentBubble])
    return;

  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_FOLLOW_WHILE_BROWSING_IPH);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `followWhileBrowsingBubbleTipPresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHFollowWhileBrowsingFeature
                    direction:arrowDirection
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(
                                  IDS_IOS_FOLLOW_WHILE_BROWSING_IPH)
                  anchorPoint:toolsMenuAnchor];
  if (presenter) {
    _followWhileBrowsingBubbleTipPresenter = presenter;
  }
  [recorder recordFollowRecommendationIPHShown];
}

- (void)presentDefaultSiteViewTipBubbleWithSettingsMap:
    (raw_ptr<HostContentSettingsMap>)settingsMap {
  if (![self canPresentBubble]) {
    return;
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState || ShouldLoadUrlInDesktopMode(
                              currentWebState->GetVisibleURL(), settingsMap)) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_DEFAULT_PAGE_MODE_TIP);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing presenter to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHDefaultSiteViewFeature
                    direction:arrowDirection
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(
                                  IDS_IOS_DEFAULT_PAGE_MODE_TIP_VOICE_OVER)
                  anchorPoint:toolsMenuAnchor];
  if (!presenter)
    return;

  _defaultPageModeTipBubblePresenter = presenter;
}

- (void)presentWhatsNewBottomToolbarBubble {
  if (![self canPresentBubble]) {
    return;
  }
  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_WHATS_NEW_IPH_TEXT);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `whatsNewBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHWhatsNewFeature
                    direction:arrowDirection
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(IDS_IOS_WHATS_NEW_IPH_TEXT)
                  anchorPoint:toolsMenuAnchor];
  if (presenter) {
    _whatsNewBubblePresenter = presenter;
  }
}

- (void)presentPriceNotificationsWhileBrowsingTipBubble {
  if (![self canPresentBubble]) {
    return;
  }
  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(
      IDS_IOS_PRICE_NOTIFICATIONS_PRICE_TRACK_TOAST_IPH_TEXT);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `whatsNewBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:
                feature_engagement::kIPHPriceNotificationsWhileBrowsingFeature
                          direction:arrowDirection
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:toolsMenuAnchor];
  if (presenter) {
    _priceNotificationsWhileBrowsingBubbleTipPresenter = presenter;
  }
}

- (void)presentLensKeyboardTipBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
  NSString* text = l10n_util::GetNSString(IDS_IOS_LENS_KEYBOARD_IPH_TEXT);
  CGPoint lensButtonAnchor = [self anchorPointToGuide:kLensKeyboardButtonGuide
                                            direction:arrowDirection];

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSLensKeyboardFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:lensButtonAnchor
                presentAction:nil
                dismissAction:nil];
  if (presenter) {
    _lensKeyboardPresenter = presenter;
  }
}

- (void)presentParcelTrackingTipBubble {
  if (![self canPresentBubble]) {
    return;
  }

  BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
  NSString* text = l10n_util::GetNSString(IDS_IOS_PARCEL_TRACKING_IPH);

  CGPoint magicStackAnchor = [self anchorPointToGuide:kMagicStackGuide
                                            direction:arrowDirection];

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSParcelTrackingFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentCenter
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:magicStackAnchor
                presentAction:nil
                dismissAction:nil];

  if (!presenter) {
    _parcelTrackingTipBubblePresenter = presenter;
  }
}

- (void)presentNewTabToolbarItemTipWithHandlerForToolbar:
            (id<ToolbarCommands>)toolbarHandler
                                             forTabStrip:(id<TabStripCommands>)
                                                             tabStripHandler
                          deviceSwitcherResultDispatcher:
                              (raw_ptr<segmentation_platform::
                                           DeviceSwitcherResultDispatcher>)
                                  deviceSwitcherResultDispatcher {
  if (!deviceSwitcherResultDispatcher ||
      !iph_for_new_chrome_user::IsUserNewSafariSwitcher(
          deviceSwitcherResultDispatcher)) {
    return;
  }

  UIView* newTabToolbarView =
      [_layoutGuideCenter referencedViewUnderName:kNewTabButtonGuide];
  // Do not present if the new tab button is not visible.
  if (!newTabToolbarView || newTabToolbarView.hidden) {
    return;
  }

  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  // Do not present the new tab IPH on NTP.
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState || IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_OPEN_NEW_TAB_IPH_TEXT);
  std::u16string newTabButtonA11yLabel = base::SysNSStringToUTF16(
      l10n_util::GetNSString(IDS_IOS_TOOLS_MENU_NEW_TAB));
  NSString* announcement = l10n_util::GetNSStringF(
      IDS_IOS_OPEN_NEW_TAB_IPH_ANNOUNCEMENT, newTabButtonA11yLabel);
  CGPoint newTabButtonAnchor = [self anchorPointToGuide:kNewTabButtonGuide
                                              direction:arrowDirection];

  // TODO(crbug.com/40265763): refactor to use CustomHighlightableButton API.
  ProceduralBlock presentAction = ^{
    [tabStripHandler setNewTabButtonOnTabStripIPHHighlighted:YES];
    [toolbarHandler setNewTabButtonIPHHighlighted:YES];
  };
  ProceduralBlock dismissAction = ^{
    [tabStripHandler setNewTabButtonOnTabStripIPHHighlighted:NO];
    [toolbarHandler setNewTabButtonIPHHighlighted:NO];
  };

  // If the feature engagement tracker does not consider it valid to display
  // the new tab tip, then end early to prevent the potential reassignment
  // of the existing `openNewTabIPHBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::
                                        kIPHiOSNewTabToolbarItemFeature
                          direction:arrowDirection
                          alignment:BubbleAlignmentBottomOrTrailing
                               text:text
              voiceOverAnnouncement:announcement
                        anchorPoint:newTabButtonAnchor
                      presentAction:presentAction
                      dismissAction:dismissAction];
  if (!presenter) {
    return;
  }

  _openNewTabIPHBubblePresenter = presenter;
}

- (void)presentTabGridToolbarItemTipWithToolbarHandler:
            (id<ToolbarCommands>)toolbarHandler
                        deviceSwitcherResultDispatcher:
                            (raw_ptr<segmentation_platform::
                                         DeviceSwitcherResultDispatcher>)
                                deviceSwitcherResultDispatcher {
  if (!deviceSwitcherResultDispatcher ||
      !iph_for_new_chrome_user::IsUserNewSafariSwitcher(
          deviceSwitcherResultDispatcher)) {
    return;
  }

  UIView* tabGridToolbarView =
      [_layoutGuideCenter referencedViewUnderName:kNewTabButtonGuide];
  // Do not present if the tab grid button is not visible.
  if (!tabGridToolbarView || tabGridToolbarView.hidden) {
    return;
  }

  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  // Only present the IPH when tab count > 1.
  if (_webStateList->count() <= 1) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_SEE_ALL_OPEN_TABS_IPH_TEXT);
  NSString* announcement =
      l10n_util::GetNSString(IDS_IOS_SEE_ALL_OPEN_TABS_IPH_ANNOUNCEMENT);
  CGPoint tabGridButtonAnchor = [self anchorPointToGuide:kTabSwitcherGuide
                                               direction:arrowDirection];

  // TODO(crbug.com/40265763): refactor to use CustomHighlightableButton API.
  auto presentAction = ^() {
    [toolbarHandler setTabGridButtonIPHHighlighted:YES];
  };
  auto dismissAction = ^() {
    [toolbarHandler setTabGridButtonIPHHighlighted:NO];
  };

  // If the feature engagement tracker does not consider it valid to display
  // the new tab tip, then end early to prevent the potential reassignment
  // of the existing `tabGridIPHBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::
                                        kIPHiOSTabGridToolbarItemFeature
                          direction:arrowDirection
                          alignment:BubbleAlignmentBottomOrTrailing
                               text:text
              voiceOverAnnouncement:announcement
                        anchorPoint:tabGridButtonAnchor
                      presentAction:presentAction
                      dismissAction:dismissAction];
  if (!presenter) {
    return;
  }

  _tabGridIPHBubblePresenter = presenter;
}

- (void)
    presentPullToRefreshGestureInProductHelpWithDeviceSwitcherResultDispatcher:
        (raw_ptr<segmentation_platform::DeviceSwitcherResultDispatcher>)
            deviceSwitcherResultDispatcher {
  if (UIAccessibilityIsVoiceOverRunning() ||
      (![self.delegate isOverscrollActionsSupportedForBubblePresenter:self]) ||
      (![self canPresentBubble])) {
    // TODO(crbug.com/41494458): Add voice over announcement once fixed.
    return;
  }
  const base::Feature& pullToRefreshFeature =
      feature_engagement::kIPHiOSPullToRefreshFeature;
  BOOL userEligibleForPullToRefreshIPH =
      deviceSwitcherResultDispatcher &&
      iph_for_new_chrome_user::IsUserNewSafariSwitcher(
          deviceSwitcherResultDispatcher) &&
      _engagementTracker->WouldTriggerHelpUI(pullToRefreshFeature);
  if (!userEligibleForPullToRefreshIPH) {
    return;
  }
  NSString* text = l10n_util::GetNSString(IDS_IOS_PULL_TO_REFRESH_IPH);
  _pullToRefreshGestureIPH =
      [self presentGestureInProductHelpForFeature:pullToRefreshFeature
                                   swipeDirection:
                                       UISwipeGestureRecognizerDirectionDown
                                             text:text];
  [_pullToRefreshGestureIPH startAnimation];
}

- (void)presentBackForwardSwipeGestureInProductHelp {
  if (UIAccessibilityIsVoiceOverRunning() ||
      (![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
    return;
  }
  const base::Feature& backForwardSwipeFeature =
      feature_engagement::kIPHiOSSwipeBackForwardFeature;
  BOOL userEligible =
      IsFirstRunRecent(base::Days(60)) &&
      _engagementTracker->WouldTriggerHelpUI(backForwardSwipeFeature);
  if (!userEligible) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  // Retrieve swipe-able directions.
  const web::NavigationManager* navigationManager =
      currentWebState->GetNavigationManager();
  BOOL back = navigationManager->CanGoBack();
  BOOL forward = navigationManager->CanGoForward();
  if (!back && !forward) {
    return;
  }
  int textId = IDS_IOS_BACK_FORWARD_SWIPE_IPH_BACK_ONLY;
  if (forward) {
    textId = back ? IDS_IOS_BACK_FORWARD_SWIPE_IPH
                  : IDS_IOS_BACK_FORWARD_SWIPE_IPH_FORWARD_ONLY;
  }

  UISwipeGestureRecognizerDirection direction =
      back ^ UseRTLLayout() ? UISwipeGestureRecognizerDirectionRight
                            : UISwipeGestureRecognizerDirectionLeft;
  _swipeBackForwardGestureIPH = [self
      presentGestureInProductHelpForFeature:backForwardSwipeFeature
                             swipeDirection:direction
                                       text:l10n_util::GetNSString(textId)];
  _swipeBackForwardGestureIPH.edgeSwipe = YES;
  if (back && forward) {
    _swipeBackForwardGestureIPH.animationRepeatCount = 4;
    _swipeBackForwardGestureIPH.bidirectional = YES;
  }
  [_swipeBackForwardGestureIPH startAnimation];
}

- (void)presentToolbarSwipeGestureInProductHelp {
  // Inapplicable on iPad.
  if (ui::GetDeviceFormFactor() !=
          ui::DeviceFormFactor::DEVICE_FORM_FACTOR_PHONE ||
      UIAccessibilityIsVoiceOverRunning() ||
      (![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
    return;
  }
  const base::Feature& feature =
      feature_engagement::kIPHiOSSwipeToolbarToChangeTabFeature;
  BOOL userEligible = IsFirstRunRecent(base::Days(60)) &&
                      _engagementTracker->WouldTriggerHelpUI(feature);
  if (!userEligible) {
    return;
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  // Check index to determine which directions are supported.
  int activeIndex = _webStateList->active_index();
  BOOL canGoBack = activeIndex > 0;
  BOOL canGoForward = activeIndex < _webStateList->count() - 1;
  if (!canGoBack && !canGoForward) {
    return;
  }
  // Setup view constraints.
  NamedGuide* contentAreaGuide =
      [NamedGuide guideWithName:kContentAreaGuide
                           view:self.rootViewController.view];
  if (!contentAreaGuide) {
    return;
  }
  UILayoutGuide* guide = [[UILayoutGuide alloc] init];
  [self.rootViewController.view addLayoutGuide:guide];
  AddSameConstraintsToSides(
      guide, contentAreaGuide,
      LayoutSides::kLeading | LayoutSides::kTrailing | LayoutSides::kBottom);
  NSLayoutConstraint* topConstraintForBottomEdgeSwipe = [guide.topAnchor
      constraintEqualToAnchor:self.rootViewController.view.topAnchor];
  NSLayoutConstraint* topConstraintForTopEdgeSwipe =
      [guide.topAnchor constraintEqualToAnchor:contentAreaGuide.topAnchor];
  NSLayoutConstraint* initialTopConstraint =
      self.rootViewController.traitCollection.verticalSizeClass ==
              UIUserInterfaceSizeClassRegular
          ? topConstraintForBottomEdgeSwipe
          : topConstraintForTopEdgeSwipe;
  initialTopConstraint.active = YES;

  // Configure IPH view.
  ToolbarSwipeGestureInProductHelpView* toolbarSwipeGestureIPH =
      [[ToolbarSwipeGestureInProductHelpView alloc]
          initWithBubbleBoundingSize:guide.layoutFrame.size
                           canGoBack:canGoBack
                             forward:canGoForward];
  [toolbarSwipeGestureIPH setTranslatesAutoresizingMaskIntoConstraints:NO];
  if (!CanGestureInProductHelpViewFitInGuide(toolbarSwipeGestureIPH, guide) ||
      !_engagementTracker->ShouldTriggerHelpUI(feature)) {
    return;
  }
  toolbarSwipeGestureIPH.topConstraintForBottomEdgeSwipe =
      topConstraintForBottomEdgeSwipe;
  toolbarSwipeGestureIPH.topConstraintForTopEdgeSwipe =
      topConstraintForTopEdgeSwipe;
  toolbarSwipeGestureIPH.delegate = self;
  [self.rootViewController.view addSubview:toolbarSwipeGestureIPH];
  AddSameConstraints(toolbarSwipeGestureIPH, guide);

  [toolbarSwipeGestureIPH startAnimation];
  _toolbarSwipeGestureIPH = toolbarSwipeGestureIPH;
}

- (void)handleToolbarSwipeGesture {
  [_toolbarSwipeGestureIPH
      dismissWithReason:IPHDismissalReasonType::
                            kSwipedAsInstructedByGestureIPH];
}

#pragma mark - GestureInProductHelpViewDelegate

- (void)gestureInProductHelpView:(GestureInProductHelpView*)view
            didDismissWithReason:(IPHDismissalReasonType)reason {
  const feature_engagement::Tracker::SnoozeAction snoozeAction =
      feature_engagement::Tracker::SnoozeAction::DISMISSED;
  std::string dismissButtonTappedEvent;
  if (view == _pullToRefreshGestureIPH) {
    dismissButtonTappedEvent =
        feature_engagement::events::kIOSPullToRefreshIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::kIPHiOSPullToRefreshFeature
                withSnooze:snoozeAction];
  } else if (view == _swipeBackForwardGestureIPH) {
    dismissButtonTappedEvent =
        feature_engagement::events::kIOSSwipeBackForwardIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::kIPHiOSSwipeBackForwardFeature
                withSnooze:snoozeAction];
  } else if (view == _toolbarSwipeGestureIPH) {
    dismissButtonTappedEvent = feature_engagement::events::
        kIOSSwipeToolbarToChangeTabIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::
                               kIPHiOSSwipeToolbarToChangeTabFeature
                withSnooze:snoozeAction];
  } else {
    NOTREACHED_IN_MIGRATION();
  }
  if (reason == IPHDismissalReasonType::kTappedClose && _engagementTracker &&
      !dismissButtonTappedEvent.empty()) {
    _engagementTracker->NotifyEvent(dismissButtonTappedEvent);
  }
}

- (void)gestureInProductHelpView:(GestureInProductHelpView*)view
    shouldHandleSwipeInDirection:(UISwipeGestureRecognizerDirection)direction {
  if (view == _pullToRefreshGestureIPH) {
    [self.delegate bubblePresenterDidPerformPullToRefreshGesture:self];
  } else if (view == _swipeBackForwardGestureIPH) {
    [self.delegate bubblePresenter:self
        didPerformSwipeToNavigateInDirection:direction];
  } else if (view == _toolbarSwipeGestureIPH) {
    // Do nothing. Swipe happens outside of the view.
  } else {
    NOTREACHED_IN_MIGRATION();
  }
}

#pragma mark - OverlayPresenterObserving

- (void)overlayPresenter:(OverlayPresenter*)presenter
    willShowOverlayForRequest:(OverlayRequest*)request
          initialPresentation:(BOOL)initialPresentation {
  [self hideAllHelpBubbles];
}

- (void)overlayPresenterDestroyed:(OverlayPresenter*)presenter {
  switch (presenter->GetModality()) {
    case OverlayModality::kWebContentArea:
      CHECK_EQ(presenter, _webContentOverlayPresenter);
      _webContentOverlayPresenter = nullptr;
      break;
    case OverlayModality::kInfobarBanner:
      CHECK_EQ(presenter, _infobarBannerPresenter);
      _infobarBannerPresenter = nullptr;
      break;
    case OverlayModality::kInfobarModal:
      CHECK_EQ(presenter, _infobarModalPresenter);
      _infobarModalPresenter = nullptr;
      break;
    case OverlayModality::kTesting:
      NOTREACHED();
  }
}

#pragma mark - Private

// Convenience method that calls -presentBubbleForFeature with default param
// values for `alignment`, `presentAction`, and `dismissAction`.
- (BubbleViewControllerPresenter*)
    presentBubbleForFeature:(const base::Feature&)feature
                  direction:(BubbleArrowDirection)direction
                       text:(NSString*)text
      voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
                anchorPoint:(CGPoint)anchorPoint {
  return [self presentBubbleForFeature:feature
                             direction:direction
                             alignment:BubbleAlignmentBottomOrTrailing
                                  text:text
                 voiceOverAnnouncement:voiceOverAnnouncement
                           anchorPoint:anchorPoint
                         presentAction:nil
                         dismissAction:nil];
}

// Presents and returns a bubble view controller for the `feature` with an arrow
// `direction`, an arrow `alignment` and a `text` on an `anchorPoint`.
- (BubbleViewControllerPresenter*)
    presentBubbleForFeature:(const base::Feature&)feature
                  direction:(BubbleArrowDirection)direction
                  alignment:(BubbleAlignment)alignment
                       text:(NSString*)text
      voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
                anchorPoint:(CGPoint)anchorPoint
              presentAction:(ProceduralBlock)presentAction
              dismissAction:(ProceduralBlock)dismissAction {
  DCHECK(_engagementTracker);
  BubbleViewControllerPresenter* presenter =
      [self bubblePresenterForFeature:feature
                            direction:direction
                            alignment:alignment
                                 text:text
                        dismissAction:dismissAction];
  if (!presenter) {
    return nil;
  }
  presenter.voiceOverAnnouncement = voiceOverAnnouncement;
  if ([presenter canPresentInView:self.rootViewController.view
                      anchorPoint:anchorPoint] &&
      ([self shouldForcePresentBubbleForFeature:feature] ||
       _engagementTracker->ShouldTriggerHelpUI(feature))) {
    [presenter presentInViewController:self.rootViewController
                           anchorPoint:anchorPoint];
    if (presentAction) {
      presentAction();
    }
  }
  return presenter;
}

// If any gesture IPH visible, remove it and log the `reason` why it should be
// removed on UMA. Otherwise, do nothing. The presenter of any gesture IPH
// should make sure it's called when the user leaves the refreshed website,
// especially while the IPH is still visible.
- (void)hideAllGestureInProductHelpViewsForReason:
    (IPHDismissalReasonType)reason {
  [_pullToRefreshGestureIPH dismissWithReason:reason];
  [_swipeBackForwardGestureIPH dismissWithReason:reason];
  [_toolbarSwipeGestureIPH dismissWithReason:reason];
}

#pragma mark - Private Utils

// Returns the anchor point for a bubble with an `arrowDirection` pointing to a
// `guideName`. The point is in the window coordinates.
- (CGPoint)anchorPointToGuide:(GuideName*)guideName
                    direction:(BubbleArrowDirection)arrowDirection {
  UILayoutGuide* guide = [_layoutGuideCenter makeLayoutGuideNamed:guideName];
  DCHECK(guide);
  [self.rootViewController.view addLayoutGuide:guide];
  CGPoint anchorPoint =
      bubble_util::AnchorPoint(guide.layoutFrame, arrowDirection);
  CGPoint anchorPointInWindow =
      [guide.owningView convertPoint:anchorPoint
                              toView:guide.owningView.window];
  [self.rootViewController.view removeLayoutGuide:guide];
  return anchorPointInWindow;
}

// Returns whether the tab can present a bubble tip.
// TODO(crbug.com/40914423): make most callsites pass NO for
// `CheckTabScrolledToTop` as it's error-prone.
- (BOOL)canPresentBubble {
  return [self canPresentBubbleWithCheckTabScrolledToTop:YES];
}

// Returns whether the tab can present a bubble tip. Whether tab being scrolled
// to top is required for presenting the bubble tip is determined by
// `checkTabScrolledToTop`.
- (BOOL)canPresentBubbleWithCheckTabScrolledToTop:(BOOL)checkTabScrolledToTop {
  // If BubblePresenter has been stopped, do not present the bubble.
  if (!_started) {
    return NO;
  }
  // If the BVC is not visible, do not present the bubble.
  if (![self.delegate rootViewVisibleForBubblePresenter:self]) {
    return NO;
  }
  // Do not present the bubble if there is no current tab.
  if (!_webStateList->GetActiveWebState()) {
    return NO;
  }
  // Do not present bubble if an overlay is showing.
  if ((_webContentOverlayPresenter &&
       _webContentOverlayPresenter->IsShowingOverlayUI()) ||
      (_infobarBannerPresenter &&
       _infobarBannerPresenter->IsShowingOverlayUI()) ||
      (_infobarModalPresenter &&
       _infobarModalPresenter->IsShowingOverlayUI())) {
    return NO;
  }
  // Do not present the bubble if the tab is not scrolled to the top.
  if (checkTabScrolledToTop && ![self isTabScrolledToTop]) {
    return NO;
  }
  return YES;
}

- (BOOL)isTabScrolledToTop {
  // If NTP exists, check if it is scrolled to top.
  if ([self.delegate isNTPActiveForBubblePresenter:self]) {
    return [self.delegate isNTPScrolledToTopForBubblePresenter:self];
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  CRWWebViewScrollViewProxy* scrollProxy =
      currentWebState->GetWebViewProxy().scrollViewProxy;
  CGPoint scrollOffset = scrollProxy.contentOffset;
  UIEdgeInsets contentInset = scrollProxy.contentInset;
  return AreCGFloatsEqual(scrollOffset.y, -contentInset.top);
}

// Returns a bubble associated with an in-product help promotion if
// it is valid to show the promotion and `nil` otherwise. `feature` is the
// base::Feature object associated with the given promotion. `direction` is the
// direction the bubble's arrow is pointing. `alignment` is the alignment of the
// arrow on the button. `text` is the text displayed by the bubble.
- (BubbleViewControllerPresenter*)
    bubblePresenterForFeature:(const base::Feature&)feature
                    direction:(BubbleArrowDirection)direction
                    alignment:(BubbleAlignment)alignment
                         text:(NSString*)text
                dismissAction:(ProceduralBlock)dismissAction {
  DCHECK(_engagementTracker);
  // Capture `weakSelf` instead of the feature engagement tracker object
  // because `weakSelf` will safely become `nil` if it is deallocated, whereas
  // the feature engagement tracker will remain pointing to invalid memory if
  // its owner (the ChromeBrowserState) is deallocated.
  __weak BubblePresenter* weakSelf = self;
  CallbackWithIPHDismissalReasonType dismissalCallbackWithSnoozeAction =
      ^(IPHDismissalReasonType IPHDismissalReasonType,
        feature_engagement::Tracker::SnoozeAction snoozeAction) {
        if (dismissAction) {
          dismissAction();
        }
        [weakSelf featureDismissed:feature withSnooze:snoozeAction];
      };

  BubbleViewControllerPresenter* bubbleViewControllerPresenter =
      [[BubbleViewControllerPresenter alloc]
          initDefaultBubbleWithText:text
                     arrowDirection:direction
                          alignment:alignment
                  dismissalCallback:dismissalCallbackWithSnoozeAction];

  bubbleViewControllerPresenter.customBubbleVisibilityDuration =
      [self bubbleVisibilityDurationForFeature:feature];

  return bubbleViewControllerPresenter;
}

// If an in-product help message should be shown for `feature`, presents an IPH
// view covering the content area and return the view, otherwise return `nil`
// and do nothing. `direction` is the direction the bubble's arrow is pointing.
// `text` is the text displayed by the bubble.
//
// Note that this method does NOT start the animation. The caller should start
// the animation of the returned `GestureInProductHelpView` accordingly. This
// allows the caller to make modifications to the view before animating.
- (GestureInProductHelpView*)
    presentGestureInProductHelpForFeature:(const base::Feature&)feature
                           swipeDirection:
                               (UISwipeGestureRecognizerDirection)direction
                                     text:(NSString*)text {
  DCHECK(_engagementTracker);
  NamedGuide* contentAreaGuide =
      [NamedGuide guideWithName:kContentAreaGuide
                           view:self.rootViewController.view];
  if (!contentAreaGuide) {
    return nil;
  }
  UILayoutGuide* boundingSizeGuide = [[UILayoutGuide alloc] init];
  UILayoutGuide* safeAreaGuide =
      self.rootViewController.view.safeAreaLayoutGuide;
  [self.rootViewController.view addLayoutGuide:boundingSizeGuide];

  BOOL isDirectionLeading = direction == UseRTLLayout()
                                ? UISwipeGestureRecognizerDirectionRight
                                : UISwipeGestureRecognizerDirectionLeft;
  switch (direction) {
    case UISwipeGestureRecognizerDirectionUp:
      AddSameConstraintsToSides(
          boundingSizeGuide, contentAreaGuide,
          LayoutSides::kLeading | LayoutSides::kTrailing | LayoutSides::kTop);
      AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                LayoutSides::kBottom);
      break;
    case UISwipeGestureRecognizerDirectionDown:
      AddSameConstraintsToSides(boundingSizeGuide, contentAreaGuide,
                                LayoutSides::kLeading | LayoutSides::kTrailing |
                                    LayoutSides::kBottom);
      AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                LayoutSides::kTop);
      break;
    case UISwipeGestureRecognizerDirectionLeft:
    case UISwipeGestureRecognizerDirectionRight:
      if (isDirectionLeading) {
        AddSameConstraintsToSides(
            boundingSizeGuide, contentAreaGuide,
            LayoutSides::kTop | LayoutSides::kBottom | LayoutSides::kLeading);
        AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                  LayoutSides::kTrailing);
      } else {
        AddSameConstraintsToSides(
            boundingSizeGuide, contentAreaGuide,
            LayoutSides::kTop | LayoutSides::kBottom | LayoutSides::kTrailing);
        AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                  LayoutSides::kLeading);
      }
      break;
  }
  GestureInProductHelpView* gestureIPHView = [[GestureInProductHelpView alloc]
            initWithText:text
      bubbleBoundingSize:boundingSizeGuide.layoutFrame.size
          swipeDirection:direction];
  [gestureIPHView setTranslatesAutoresizingMaskIntoConstraints:NO];
  if (CanGestureInProductHelpViewFitInGuide(gestureIPHView,
                                            boundingSizeGuide) &&
      _engagementTracker->ShouldTriggerHelpUI(feature)) {
    [self.rootViewController.view addSubview:gestureIPHView];
    gestureIPHView.delegate = self;
    AddSameConstraints(gestureIPHView, contentAreaGuide);
    return gestureIPHView;
  }
  return nil;
}

- (void)featureDismissed:(const base::Feature&)feature
              withSnooze:
                  (feature_engagement::Tracker::SnoozeAction)snoozeAction {
  if (!_engagementTracker) {
    return;
  }
  _engagementTracker->DismissedWithSnooze(feature, snoozeAction);
}

// Returns the custom duration of the bubble for `feature`, or 0 if there is
// none.
- (NSTimeInterval)bubbleVisibilityDurationForFeature:
    (const base::Feature&)feature {
  // Display FollowWhileBrowsing in-product help bubble with custom duration.
  if (feature.name == feature_engagement::kIPHFollowWhileBrowsingFeature.name) {
    return kDefaultLongDurationBubbleVisibility;
  }

  return 0;
}

// Return YES if the bubble should always be presented. Ex. if force present
// bubble set by system experimental settings.
- (BOOL)shouldForcePresentBubbleForFeature:(const base::Feature&)feature {
  // Always present follow IPH if it's triggered by system experimental
  // settings.
  if (feature.name == feature_engagement::kIPHFollowWhileBrowsingFeature.name &&
      experimental_flags::ShouldAlwaysShowFollowIPH()) {
    return YES;
  }

  return NO;
}

// Stop observing overlay events and disconnect related properties.
- (void)disconnectOverlayPresenters {
  if (_webContentOverlayPresenter) {
    _webContentOverlayPresenter->RemoveObserver(
        _overlayPresenterObserver.get());
    _webContentOverlayPresenter = nullptr;
  }
  if (_infobarBannerPresenter) {
    _infobarBannerPresenter->RemoveObserver(_overlayPresenterObserver.get());
    _infobarBannerPresenter = nullptr;
  }
  if (_infobarModalPresenter) {
    _infobarModalPresenter->RemoveObserver(_overlayPresenterObserver.get());
    _infobarModalPresenter = nullptr;
  }
  _overlayPresenterObserver = nullptr;
}

@end