chromium/ios/chrome/browser/ui/popup_menu/popup_menu_help_coordinator.mm

// Copyright 2022 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/ui/popup_menu/popup_menu_help_coordinator.h"

#import "base/memory/raw_ptr.h"
#import "base/strings/sys_string_conversions.h"
#import "base/task/sequenced_task_runner.h"
#import "base/time/time.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/segmentation_platform/embedder/default_model/device_switcher_result_dispatcher.h"
#import "components/sync/service/sync_service.h"
#import "ios/chrome/app/tests_hook.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_constants.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view_controller_presenter.h"
#import "ios/chrome/browser/default_browser/model/utils.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/segmentation_platform/model/segmentation_platform_service_factory.h"
#import "ios/chrome/browser/settings/model/sync/utils/identity_error_util.h"
#import "ios/chrome/browser/shared/coordinator/layout_guide/layout_guide_util.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.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/browser/sync/model/sync_service_factory.h"
#import "ios/chrome/browser/ui/popup_menu/overflow_menu/feature_flags.h"
#import "ios/chrome/browser/ui/popup_menu/overflow_menu/overflow_menu_action_provider.h"
#import "ios/chrome/browser/ui/popup_menu/overflow_menu/overflow_menu_constants.h"
#import "ios/chrome/browser/ui/popup_menu/overflow_menu/overflow_menu_swift.h"
#import "ios/chrome/browser/ui/popup_menu/public/popup_menu_ui_updating.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

base::TimeDelta kPromoDisplayDelayForTests = base::Seconds(1);
}  // namespace

@interface PopupMenuHelpCoordinator () <SceneStateObserver>

// Bubble view controller presenter for popup menu tip.
@property(nonatomic, strong)
    BubbleViewControllerPresenter* popupMenuBubblePresenter;

// Bubble view controller presenter for the Overflow Menu tips.
@property(nonatomic, strong)
    BubbleViewControllerPresenter* overflowMenuBubblePresenter;

// The browser state. May return null after the coordinator has been stopped
// (thus the returned value must be checked for null).
@property(nonatomic, readonly) ChromeBrowserState* browserState;

// The layout guide installed in the base view controller on which to anchor the
// potential IPH bubble.
@property(nonatomic, strong) UILayoutGuide* layoutGuide;

// Whether the user is still in the same session as when the history menu item
// IPH was triggered
@property(nonatomic, assign) BOOL inSessionWithHistoryMenuItemIPH;

// The tracker for feature engagement. May return null after the coordinator has
// been stopped (thus the returned value must be checked for null).
@property(nonatomic, readonly)
    feature_engagement::Tracker* featureEngagementTracker;

// Whether overflow menu button has a blue dot.
@property(nonatomic, assign) BOOL hasBlueDot;

@end

@implementation PopupMenuHelpCoordinator {
  raw_ptr<segmentation_platform::DeviceSwitcherResultDispatcher>
      _deviceSwitcherResultDispatcher;
}

- (instancetype)initWithBaseViewController:(UIViewController*)viewController
                                   browser:(Browser*)browser {
  self = [super initWithBaseViewController:viewController browser:browser];
  if (self) {
    if (!browser->GetBrowserState()->IsOffTheRecord()) {
      _deviceSwitcherResultDispatcher =
          segmentation_platform::SegmentationPlatformServiceFactory::
              GetDispatcherForBrowserState(browser->GetBrowserState());
    }
  }
  return self;
}

#pragma mark - Getters

- (ChromeBrowserState*)browserState {
  return self.browser ? self.browser->GetBrowserState() : nullptr;
}

- (feature_engagement::Tracker*)featureEngagementTracker {
  ChromeBrowserState* browserState = self.browserState;
  if (!browserState)
    return nullptr;
  feature_engagement::Tracker* tracker =
      feature_engagement::TrackerFactory::GetForBrowserState(browserState);
  DCHECK(tracker);
  return tracker;
}

#pragma mark - Public methods

- (void)start {
  SceneState* sceneState = self.browser->GetSceneState();
  [sceneState addObserver:self];

  LayoutGuideCenter* layoutGuideCenter =
      LayoutGuideCenterForBrowser(self.browser);
  self.layoutGuide = [layoutGuideCenter makeLayoutGuideNamed:kToolsMenuGuide];
  [self.baseViewController.view addLayoutGuide:self.layoutGuide];
}

- (void)stop {
  SceneState* sceneState = self.browser->GetSceneState();
  [sceneState removeObserver:self];
}

- (NSNumber*)highlightDestination {
  if (self.inSessionWithHistoryMenuItemIPH) {
    return [NSNumber numberWithInt:static_cast<NSInteger>(
                                       overflow_menu::Destination::History)];
  }
  return nil;
}

- (void)showIPHAfterOpenOfOverflowMenu:(UIViewController*)menu {
  if ([self showHistoryOnOverflowMenuIPHInViewController:menu]) {
    return;
  }
  // Only try to show customization IPH if history IPH was not shown.
  [self showCustomizationIPHInMenu:menu];
}

- (BOOL)hasBlueDotForOverflowMenu {
  return self.hasBlueDot;
}

#pragma mark - Private

// Shows the History IPH when the overflow menu opens. Returns `YES` if the IPH
// was successfully shown or `NO` if it was not.
- (BOOL)showHistoryOnOverflowMenuIPHInViewController:(UIViewController*)menu {
  // Show the IPH in the overflow menu if user is still in a session where they
  // saw the IPH of the three-dot menu item.
  if (!self.inSessionWithHistoryMenuItemIPH) {
    return NO;
  }

  CGFloat anchorXInParent =
      CGRectGetMidX(self.uiConfiguration.highlightedDestinationFrame);
  CGFloat anchorX =
      [menu.view.window convertPoint:CGPointMake(anchorXInParent, 0)
                            fromView:menu.view]
          .x;
  // in global coordinate system
  CGPoint anchorPoint = CGPointMake(
      anchorX, CGRectGetMaxY(self.uiConfiguration.destinationListScreenFrame));

  self.overflowMenuBubblePresenter = [self
      newOverflowMenuBubblePresenterWithAnchorXInParent:anchorXInParent
                                        parentViewWidth:
                                            self.uiConfiguration
                                                .destinationListScreenFrame.size
                                                .width];

  if (![self.overflowMenuBubblePresenter canPresentInView:menu.view
                                              anchorPoint:anchorPoint]) {
    // Reset the highlight status of the destination as we will miss the other
    // path of resetting it when dismissing the IPH.
    self.uiConfiguration.highlightDestination = -1;
    // No effect besides leaving it in a clean state.
    self.uiConfiguration.highlightedDestinationFrame = CGRectZero;
    return NO;
  }

  self.inSessionWithHistoryMenuItemIPH = NO;

  [self.overflowMenuBubblePresenter presentInViewController:menu
                                                anchorPoint:anchorPoint];
  return YES;
}

// Possibly shows the IPH for the Overflow Menu Customization feature. Returns
// whether or not the IPH was shown.
- (BOOL)showCustomizationIPHInMenu:(UIViewController*)menu {
  if (!IsOverflowMenuCustomizationEnabled()) {
    return NO;
  }

  // In global coordinate system
  CGPoint anchorPointInView = CGPointMake(CGRectGetMaxX(menu.view.frame) / 2,
                                          CGRectGetMaxY(menu.view.frame) - 20);
  CGPoint anchorPoint = [menu.view.window convertPoint:anchorPointInView
                                              fromView:menu.view];

  self.overflowMenuBubblePresenter =
      [self newOverflowMenuCustomizationBubblePresenter];

  if (![self.overflowMenuBubblePresenter canPresentInView:menu.view
                                              anchorPoint:anchorPoint]) {
    return NO;
  }

  if (![self canShowOverflowMenuCustomizationIPH]) {
    self.overflowMenuBubblePresenter = nil;
    return NO;
  }

  [self.overflowMenuBubblePresenter presentInViewController:menu
                                                anchorPoint:anchorPoint];

  OverflowMenuAction* editActionsAction = [self.actionProvider
      actionForActionType:overflow_menu::ActionType::EditActions];
  editActionsAction.highlighted = YES;
  editActionsAction.displayNewLabelIcon = YES;

  return YES;
}

- (void)scrollToEditActionsButton {
  self.uiConfiguration.scrollToAction = [self.actionProvider
      actionForActionType:overflow_menu::ActionType::EditActions];
}

// Returns whether blue dot should be shown.
- (BOOL)shouldShowBlueDot {
  // As sync error takes precendence on blue dot for settings destination in the
  // overflow menu. In that case don't show blue dot as the full path from
  // toolbar to default browser settings cannot be highlighted.
  syncer::SyncService* syncService =
      SyncServiceFactory::GetForBrowserState(self.browser->GetBrowserState());
  if (syncService && ShouldIndicateIdentityErrorInOverflowMenu(syncService)) {
    return NO;
  }

  if (self.featureEngagementTracker &&
      ShouldTriggerDefaultBrowserHighlightFeature(
          self.featureEngagementTracker)) {
    RecordDefaultBrowserBlueDotFirstDisplay();
    return YES;
  }
  return NO;
}

#pragma mark - Popup Menu Button Bubble/IPH methods

- (BubbleViewControllerPresenter*)newPopupMenuBubblePresenter {
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_VIEW_BROWSING_HISTORY_OVERFLOW_MENU_TIP);

  // Prepare the dismissal callback.
  __weak __typeof(self) weakSelf = self;
  CallbackWithIPHDismissalReasonType dismissalCallback =
      ^(IPHDismissalReasonType IPHDismissalReasonType,
        feature_engagement::Tracker::SnoozeAction snoozeAction) {
        [weakSelf popupMenuIPHDidDismissWithReasonType:IPHDismissalReasonType
                                          SnoozeAction:snoozeAction];
      };

  // Create the BubbleViewControllerPresenter.
  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.baseViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  BubbleViewControllerPresenter* bubbleViewControllerPresenter =
      [[BubbleViewControllerPresenter alloc]
          initDefaultBubbleWithText:text
                     arrowDirection:arrowDirection
                          alignment:BubbleAlignmentBottomOrTrailing
                  dismissalCallback:dismissalCallback];
  std::u16string menuButtonA11yLabel = base::SysNSStringToUTF16(
      l10n_util::GetNSString(IDS_IOS_TOOLBAR_SETTINGS));
  bubbleViewControllerPresenter.voiceOverAnnouncement = l10n_util::GetNSStringF(
      IDS_IOS_VIEW_BROWSING_HISTORY_FROM_MENU_ANNOUNCEMENT,
      menuButtonA11yLabel);
  return bubbleViewControllerPresenter;
}

- (void)popupMenuIPHDidDismissWithReasonType:
            (IPHDismissalReasonType)IPHDismissalReasonType
                                SnoozeAction:
                                    (feature_engagement::Tracker::SnoozeAction)
                                        snoozeAction {
  if (IPHDismissalReasonType == IPHDismissalReasonType::kTappedAnchorView ||
      IPHDismissalReasonType == IPHDismissalReasonType::kTimedOut) {
    self.inSessionWithHistoryMenuItemIPH = YES;
  }
  [self trackerIPHDidDismissWithSnoozeAction:snoozeAction];
  [self.UIUpdater updateUIForIPHDismissed];
  self.popupMenuBubblePresenter = nil;
}

- (void)prepareToShowPopupMenuBubble {
  // There must be a feature engagment tracker to show a bubble.
  if (!self.featureEngagementTracker) {
    return;
  }

  // If the Feature Engagement Tracker isn't ready, queue up and re-show when
  // it has finished initializing.
  if (!self.featureEngagementTracker->IsInitialized()) {
    __weak __typeof(self) weakSelf = self;
    self.featureEngagementTracker->AddOnInitializedCallback(
        base::BindRepeating(^(bool success) {
          if (!success) {
            return;
          }
          [weakSelf prepareToShowPopupMenuBubble];
        }));
    return;
  }

  if (tests_hook::DelayAppLaunchPromos()) {
    __weak __typeof(self) weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
                                 kPromoDisplayDelayForTests.InNanoseconds()),
                   dispatch_get_main_queue(), ^{
                     [weakSelf showPopupMenuBubbleIfNecessary];
                   });
  } else {
    [self showPopupMenuBubbleIfNecessary];
  }
}

- (void)showPopupMenuBubbleIfNecessary {
  // Skip if a presentation is already in progress
  if (self.popupMenuBubblePresenter) {
    return;
  }

  BubbleViewControllerPresenter* bubblePresenter =
      [self newPopupMenuBubblePresenter];

  // Get the anchor point for the bubble. In Split Toolbar Mode, the anchor
  // button is at the bottom of the screen, so the bubble should be above it.
  // When there's only one toolbar, the anchor button is at the top of the
  // screen, so the bubble should be below it.
  CGRect anchorFrame = self.layoutGuide.layoutFrame;
  CGFloat anchorPointY = IsSplitToolbarMode(self.baseViewController)
                             ? CGRectGetMinY(anchorFrame)
                             : CGRectGetMaxY(anchorFrame);
  CGPoint anchorPoint = CGPointMake(CGRectGetMidX(anchorFrame), anchorPointY);

  // Discard if it doesn't fit in the view as it is currently shown.
  if (![bubblePresenter canPresentInView:self.baseViewController.view
                             anchorPoint:anchorPoint]) {
    return;
  }

  // Early return if the engagement tracker won't display the IPH.
  if (![self canShowIPHForPopupMenu]) {
    return;
  }

  // Present the bubble after the delay.
  self.popupMenuBubblePresenter = bubblePresenter;
  [self.popupMenuBubblePresenter
      presentInViewController:self.baseViewController
                  anchorPoint:anchorPoint
              anchorViewFrame:anchorFrame];
  [self.UIUpdater updateUIForOverflowMenuIPHDisplayed];
}

- (void)updateBlueDotVisibility {
  BOOL hasBlueDot = YES;

  // Don't show blue dot if already showing another IPH.
  if (self.popupMenuBubblePresenter) {
    hasBlueDot = NO;
  }

  if (![self shouldShowBlueDot]) {
    hasBlueDot = NO;
  }

  [self.UIUpdater setOverflowMenuBlueDot:hasBlueDot];
  self.hasBlueDot = hasBlueDot;
}

#pragma mark - Overflow Menu Bubble methods

- (BubbleViewControllerPresenter*)
    newOverflowMenuBubblePresenterWithAnchorXInParent:(CGFloat)anchorXInParent
                                      parentViewWidth:(CGFloat)parentViewWidth {
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_VIEW_BROWSING_HISTORY_OVERFLOW_MENU_TIP);

  // Prepare the dismissal callback.
  __weak __typeof(self) weakSelf = self;
  CallbackWithIPHDismissalReasonType dismissalCallback =
      ^(IPHDismissalReasonType IPHDismissalReasonType,
        feature_engagement::Tracker::SnoozeAction snoozeAction) {
        [weakSelf overflowMenuIPHDidDismissWithSnoozeAction:snoozeAction];
      };

  BubbleAlignment alignment = anchorXInParent < 0.5 * parentViewWidth
                                  ? BubbleAlignmentTopOrLeading
                                  : BubbleAlignmentBottomOrTrailing;

  // Create the BubbleViewControllerPresenter.
  BubbleArrowDirection arrowDirection = BubbleArrowDirectionUp;
  BubbleViewControllerPresenter* bubbleViewControllerPresenter =
      [[BubbleViewControllerPresenter alloc]
          initDefaultBubbleWithText:text
                     arrowDirection:arrowDirection
                          alignment:alignment
                  dismissalCallback:dismissalCallback];
  std::u16string historyButtonA11yLabel = base::SysNSStringToUTF16(
      l10n_util::GetNSString(IDS_IOS_TOOLS_MENU_HISTORY));
  bubbleViewControllerPresenter.voiceOverAnnouncement = l10n_util::GetNSStringF(
      IDS_IOS_VIEW_BROWSING_HISTORY_BY_SELECTING_HISTORY_TIP_ANNOUNCEMENT,
      historyButtonA11yLabel);
  return bubbleViewControllerPresenter;
}

- (void)overflowMenuIPHDidDismissWithSnoozeAction:
    (feature_engagement::Tracker::SnoozeAction)snoozeAction {
  self.overflowMenuBubblePresenter = nil;
  self.uiConfiguration.highlightDestination = -1;
}

#pragma mark - Overflow Menu Customization Methods

- (BubbleViewControllerPresenter*)newOverflowMenuCustomizationBubblePresenter {
  NSString* text = l10n_util::GetNSString(IDS_IOS_TOOLS_MENU_CUSTOMIZATION_IPH);

  // Prepare the dismissal callback.
  __weak __typeof(self) weakSelf = self;
  CallbackWithIPHDismissalReasonType dismissalCallback = ^(
      IPHDismissalReasonType IPHDismissalReasonType,
      feature_engagement::Tracker::SnoozeAction snoozeAction) {
    if (IPHDismissalReasonType == IPHDismissalReasonType::kTappedIPH) {
      [self scrollToEditActionsButton];
    }
    [weakSelf
        overflowMenuCustomizationIPHDidDismissWithSnoozeAction:snoozeAction];
  };

  BubbleAlignment alignment = BubbleAlignmentCenter;

  // Create the BubbleViewControllerPresenter.
  BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
  BubbleViewControllerPresenter* bubbleViewControllerPresenter =
      [[BubbleViewControllerPresenter alloc]
          initDefaultBubbleWithText:text
                     arrowDirection:arrowDirection
                          alignment:alignment
                  dismissalCallback:dismissalCallback];

  bubbleViewControllerPresenter.customBubbleVisibilityDuration =
      kDefaultLongDurationBubbleVisibility;

  return bubbleViewControllerPresenter;
}

- (void)overflowMenuCustomizationIPHDidDismissWithSnoozeAction:
    (feature_engagement::Tracker::SnoozeAction)snoozeAction {
  feature_engagement::Tracker* tracker = self.featureEngagementTracker;
  if (tracker) {
    const base::Feature& feature =
        feature_engagement::kIPHiOSOverflowMenuCustomizationFeature;
    tracker->Dismissed(feature);
  }
  self.overflowMenuBubblePresenter = nil;
}

#pragma mark - SceneStateObserver

- (void)sceneState:(SceneState*)sceneState
    transitionedToActivationLevel:(SceneActivationLevel)level {
  if (level <= SceneActivationLevelBackground) {
    self.inSessionWithHistoryMenuItemIPH = NO;
  } else if (level >= SceneActivationLevelForegroundActive) {
    [self prepareToShowPopupMenuBubble];
    [self updateBlueDotVisibility];
  }
}

#pragma mark - Feature Engagement Tracker queries

// Queries the feature engagement tracker to see if IPH can currently be
// displayed. Once this is returning YES, the IPH MUST be shown and dismissed.
- (BOOL)canShowIPHForPopupMenu {
  if (!iph_for_new_chrome_user::IsUserNewSafariSwitcher(
          _deviceSwitcherResultDispatcher)) {
    return NO;
  }
  feature_engagement::Tracker* tracker = self.featureEngagementTracker;
  const base::Feature& feature =
      feature_engagement::kIPHiOSHistoryOnOverflowMenuFeature;
  return tracker && tracker->ShouldTriggerHelpUI(feature);
}

// Alerts the feature engagement tracker that a shown IPH was dismissed.
- (void)trackerIPHDidDismissWithSnoozeAction:
    (feature_engagement::Tracker::SnoozeAction)snoozeAction {
  feature_engagement::Tracker* tracker = self.featureEngagementTracker;
  if (tracker) {
    const base::Feature& feature =
        feature_engagement::kIPHiOSHistoryOnOverflowMenuFeature;
    tracker->DismissedWithSnooze(feature, snoozeAction);
  }
}

// Queries the feature engagement tracker to see if the Overflow Menu
// Customization IPH can be displayed. If this returns YES, the IPH MUST be
// shown and dismissed.
- (BOOL)canShowOverflowMenuCustomizationIPH {
  feature_engagement::Tracker* tracker = self.featureEngagementTracker;
  const base::Feature& feature =
      feature_engagement::kIPHiOSOverflowMenuCustomizationFeature;
  return tracker && tracker->ShouldTriggerHelpUI(feature);
}

@end