blob: 48373ddec9e9879b258f7f6ed180f6f8152dd445 [file] [log] [blame]
// 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/ui/bubble/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/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/shared/model/browser_state/chrome_browser_state.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.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/uikit_ui_util.h"
#import "ios/chrome/browser/shared/ui/util/util_swift.h"
#import "ios/chrome/browser/ui/bubble/bubble_constants.h"
#import "ios/chrome/browser/ui/bubble/bubble_presenter_delegate.h"
#import "ios/chrome/browser/ui/bubble/bubble_util.h"
#import "ios/chrome/browser/ui/bubble/bubble_view_controller_presenter.h"
#import "ios/chrome/browser/ui/bubble/gesture_iph/gesture_in_product_help_view.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 ()
// Used to display the bottom toolbar tip in-product help promotion bubble.
// `nil` if the tip bubble has not yet been presented. Once the bubble is
// dismissed, it remains allocated so that `userEngaged` remains accessible.
@property(nonatomic, strong)
BubbleViewControllerPresenter* bottomToolbarTipBubblePresenter;
// Used to display the new tab tip in-product help promotion bubble. `nil` if
// the new tab tip bubble has not yet been presented. Once the bubble is
// dismissed, it remains allocated so that `userEngaged` remains accessible.
@property(nonatomic, strong)
BubbleViewControllerPresenter* openNewTabIPHBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* sharePageIPHBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* tabGridIPHBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* discoverFeedHeaderMenuTipBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* readingListTipBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* followWhileBrowsingBubbleTipPresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* defaultPageModeTipBubblePresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* whatsNewBubblePresenter;
@property(nonatomic, strong) BubbleViewControllerPresenter*
priceNotificationsWhileBrowsingBubbleTipPresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* lensKeyboardPresenter;
@property(nonatomic, strong)
BubbleViewControllerPresenter* parcelTrackingTipBubblePresenter;
@property(nonatomic, strong) GestureInProductHelpView* pullToRefreshGestureIPH;
@property(nonatomic, strong)
GestureInProductHelpView* swipeBackForwardGestureIPH;
@property(nonatomic, assign) WebStateList* webStateList;
@property(nonatomic, assign) feature_engagement::Tracker* engagementTracker;
@property(nonatomic, assign) HostContentSettingsMap* settingsMap;
// Whether the presenter is started.
@property(nonatomic, assign, getter=isStarted) BOOL started;
@end
@implementation BubblePresenter {
raw_ptr<segmentation_platform::DeviceSwitcherResultDispatcher>
_deviceSwitcherResultDispatcher;
raw_ptr<PrefService> _prefService;
id<TabStripCommands> _tabStripCommandsHandler;
}
#pragma mark - Public
- (instancetype)
initWithDeviceSwitcherResultDispatcher:
(segmentation_platform::DeviceSwitcherResultDispatcher*)
deviceSwitcherResultDispatcher
hostContentSettingsMap:(HostContentSettingsMap*)settingsMap
prefService:(PrefService*)prefService
tabStripCommandsHandler:
(id<TabStripCommands>)tabStripCommandsHandler
tracker:(feature_engagement::Tracker*)
engagementTracker
webStateList:(WebStateList*)webStateList {
self = [super init];
if (self) {
CHECK(prefService);
DCHECK(webStateList);
_webStateList = webStateList;
_engagementTracker = engagementTracker;
_settingsMap = settingsMap;
_deviceSwitcherResultDispatcher = deviceSwitcherResultDispatcher;
_prefService = prefService;
_tabStripCommandsHandler = tabStripCommandsHandler;
self.started = YES;
}
return self;
}
- (void)stop {
[self hideAllHelpBubbles];
self.started = NO;
self.webStateList = nullptr;
self.engagementTracker = nullptr;
self.settingsMap = nullptr;
}
- (void)hideAllHelpBubbles {
[self.sharePageIPHBubblePresenter dismissAnimated:NO];
[self.openNewTabIPHBubblePresenter dismissAnimated:NO];
[self.tabGridIPHBubblePresenter dismissAnimated:NO];
[self.bottomToolbarTipBubblePresenter dismissAnimated:NO];
[self.discoverFeedHeaderMenuTipBubblePresenter dismissAnimated:NO];
[self.readingListTipBubblePresenter dismissAnimated:NO];
[self.followWhileBrowsingBubbleTipPresenter dismissAnimated:NO];
[self.priceNotificationsWhileBrowsingBubbleTipPresenter dismissAnimated:NO];
[self.whatsNewBubblePresenter dismissAnimated:NO];
[self.lensKeyboardPresenter dismissAnimated:NO];
[self.defaultPageModeTipBubblePresenter dismissAnimated:NO];
[self.parcelTrackingTipBubblePresenter dismissAnimated:NO];
[self hideAllGestureInProductHelpViewsForReason:IPHDismissalReasonType::
kUnknown];
}
- (void)handleTapOutsideOfVisibleGestureInProductHelp {
[self hideAllGestureInProductHelpViewsForReason:
IPHDismissalReasonType::kTappedOutsideIPHAndAnchorView];
}
- (void)presentShareButtonHelpBubbleIfEligible {
if (!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 = IsBottomOmniboxSteadyStateEnabled() &&
_prefService->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;
}
self.sharePageIPHBubblePresenter = presenter;
}
- (void)presentDiscoverFeedHeaderTipBubble {
BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
NSString* text =
l10n_util::GetNSStringWithFixup(IDS_IOS_DISCOVER_FEED_HEADER_IPH);
UIView* menuButton = [self.layoutGuideCenter
referencedViewUnderName:kDiscoverFeedHeaderMenuGuide];
// 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 discoverFeedHeaderAnchor =
[menuButton.superview convertPoint:menuButton.frame.origin toView:nil];
// Anchor the IPH 1/3 of the way through the button. Anchoring it midway
// doesn't work since the button is too close to the edge, which would cause
// the bubble to bleed out the screen.
discoverFeedHeaderAnchor.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
text:text
voiceOverAnnouncement:nil
anchorPoint:discoverFeedHeaderAnchor];
if (!presenter)
return;
self.discoverFeedHeaderMenuTipBubblePresenter = presenter;
}
- (void)presentFollowWhileBrowsingTipBubble {
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)
return;
self.followWhileBrowsingBubbleTipPresenter = presenter;
}
- (void)presentDefaultSiteViewTipBubble {
if (![self canPresentBubble])
return;
web::WebState* currentWebState = self.webStateList->GetActiveWebState();
if (!currentWebState ||
ShouldLoadUrlInDesktopMode(currentWebState->GetVisibleURL(),
self.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;
self.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)
return;
self.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)
return;
self.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) {
return;
}
self.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) {
return;
}
self.parcelTrackingTipBubblePresenter = presenter;
}
- (void)presentNewTabToolbarItemBubble {
if (!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 = self.webStateList->GetActiveWebState();
if (!currentWebState ||
currentWebState->GetVisibleURL() == kChromeUINewTabURL) {
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];
__weak id<ToolbarCommands> weakToolbarCommandsHandler =
_toolbarCommandsHandler;
__weak id<TabStripCommands> weakTabStripCommandsHandler =
_tabStripCommandsHandler;
// TODO(crbug.com/1439920): refactor to use CustomHighlightableButton API.
ProceduralBlock presentAction = ^{
[weakTabStripCommandsHandler setNewTabButtonOnTabStripIPHHighlighted:YES];
[weakToolbarCommandsHandler setNewTabButtonIPHHighlighted:YES];
};
ProceduralBlock dismissAction = ^{
[weakTabStripCommandsHandler setNewTabButtonOnTabStripIPHHighlighted:NO];
[weakToolbarCommandsHandler 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;
}
self.openNewTabIPHBubblePresenter = presenter;
}
// Optionally presents a bubble associated with the tab grid iph. If the feature
// engagement tracker determines it is valid to show the new tab tip, then it
// initializes `tabGridIPHBubblePresenter` and presents the bubble. If it is
// not valid to show the new tab tip, `tabGridIPHBubblePresenter` is set to
// `nil` and no bubble is shown. This method requires that `self.browserState`
// is not NULL.
- (void)presentTabGridToolbarItemBubble {
if (!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 (self.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];
__weak id<ToolbarCommands> weakToolbarCommandsHandler =
_toolbarCommandsHandler;
// TODO(crbug.com/1439920): refactor to use CustomHighlightableButton API.
auto presentAction = ^() {
[weakToolbarCommandsHandler setTabGridButtonIPHHighlighted:YES];
};
auto dismissAction = ^() {
[weakToolbarCommandsHandler 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;
}
self.tabGridIPHBubblePresenter = presenter;
}
- (void)presentPullToRefreshGestureInProductHelp {
if (UIAccessibilityIsVoiceOverRunning() || (![self canPresentBubble])) {
// TODO(crbug.com/1521489): Add voice over announcement once fixed.
return;
}
const base::Feature& pullToRefreshFeature =
feature_engagement::kIPHiOSPullToRefreshFeature;
BOOL userEligibleForPullToRefreshIPH =
iph_for_new_chrome_user::IsUserNewSafariSwitcher(
_deviceSwitcherResultDispatcher) &&
self.engagementTracker->WouldTriggerHelpUI(pullToRefreshFeature);
if (!userEligibleForPullToRefreshIPH) {
return;
}
__weak BubblePresenter* weakSelf = self;
NSString* text = l10n_util::GetNSString(IDS_IOS_PULL_TO_REFRESH_IPH);
ProceduralBlock resetPullToRefreshGestureIPH = ^{
weakSelf.pullToRefreshGestureIPH = nil;
};
self.pullToRefreshGestureIPH =
[self presentGestureInProductHelpForFeature:pullToRefreshFeature
direction:BubbleArrowDirectionUp
text:text
dismissAction:resetPullToRefreshGestureIPH];
[self.pullToRefreshGestureIPH startAnimation];
}
- (void)presentBackForwardSwipeGestureInProductHelp {
if (UIAccessibilityIsVoiceOverRunning() ||
(![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
return;
}
const base::Feature& backForwardSwipeFeature =
feature_engagement::kIPHiOSSwipeBackForwardFeature;
BOOL userEligible =
IsFirstRunRecent(base::Days(60)) &&
self.engagementTracker->WouldTriggerHelpUI(backForwardSwipeFeature);
if (!userEligible) {
return;
}
web::WebState* currentWebState = self.webStateList->GetActiveWebState();
if (currentWebState->GetVisibleURL() == kChromeUINewTabURL) {
return;
}
// Retrieve swipe-able directions.
const web::NavigationManager* navigationManager =
currentWebState->GetNavigationManager();
BOOL back = navigationManager->CanGoBack();
BOOL forward = navigationManager->CanGoForward();
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;
}
__weak BubblePresenter* weakSelf = self;
ProceduralBlock resetSwipeBackForwardGestureIPH = ^{
weakSelf.swipeBackForwardGestureIPH = nil;
};
self.swipeBackForwardGestureIPH = [self
presentGestureInProductHelpForFeature:backForwardSwipeFeature
direction:back ? BubbleArrowDirectionLeading
: BubbleArrowDirectionTrailing
text:l10n_util::GetNSString(textId)
dismissAction:resetSwipeBackForwardGestureIPH];
if (back && forward) {
self.swipeBackForwardGestureIPH.animationRepeatCount = 4;
self.swipeBackForwardGestureIPH.bidirectional = YES;
}
[self.swipeBackForwardGestureIPH startAnimation];
}
#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(self.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] ||
self.engagementTracker->ShouldTriggerHelpUI(feature))) {
[presenter presentInViewController:self.rootViewController
view:self.rootViewController.view
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 {
[self.pullToRefreshGestureIPH dismissWithReason:reason];
[self.swipeBackForwardGestureIPH 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 =
[self.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/1448656): 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 (!self.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 (!self.webStateList->GetActiveWebState()) {
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 = self.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(self.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
isLongDurationBubble:[self isLongDurationBubble:feature]
dismissalCallback:dismissalCallbackWithSnoozeAction];
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. `dismissAction` is the callback
// function invoked when the IPH is dismissed.
//
// 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
direction:(BubbleArrowDirection)direction
text:(NSString*)text
dismissAction:(ProceduralBlock)dismissAction {
DCHECK(self.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];
switch (direction) {
case BubbleArrowDirectionUp:
AddSameConstraintsToSides(boundingSizeGuide, contentAreaGuide,
LayoutSides::kLeading | LayoutSides::kTrailing |
LayoutSides::kBottom);
AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
LayoutSides::kTop);
break;
case BubbleArrowDirectionDown:
AddSameConstraintsToSides(
boundingSizeGuide, contentAreaGuide,
LayoutSides::kLeading | LayoutSides::kTrailing | LayoutSides::kTop);
AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
LayoutSides::kBottom);
break;
case BubbleArrowDirectionLeading:
AddSameConstraintsToSides(
boundingSizeGuide, contentAreaGuide,
LayoutSides::kTop | LayoutSides::kBottom | LayoutSides::kTrailing);
AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
LayoutSides::kLeading);
break;
case BubbleArrowDirectionTrailing:
AddSameConstraintsToSides(
boundingSizeGuide, contentAreaGuide,
LayoutSides::kTop | LayoutSides::kBottom | LayoutSides::kLeading);
AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
LayoutSides::kTrailing);
break;
}
GestureInProductHelpView* gestureIPHView = [[GestureInProductHelpView alloc]
initWithText:text
bubbleBoundingSize:boundingSizeGuide.layoutFrame.size
arrowDirection:direction];
[gestureIPHView setTranslatesAutoresizingMaskIntoConstraints:NO];
if (CanGestureInProductHelpViewFitInGuide(gestureIPHView,
boundingSizeGuide) &&
self.engagementTracker->ShouldTriggerHelpUI(feature)) {
__weak BubblePresenter* weakSelf = self;
CallbackWithIPHDismissalReasonType dismissalCallbackWithSnoozeAction =
^(IPHDismissalReasonType IPHDismissalReasonType,
feature_engagement::Tracker::SnoozeAction snoozeAction) {
if (dismissAction) {
dismissAction();
}
[weakSelf featureDismissed:feature withSnooze:snoozeAction];
};
gestureIPHView.dismissCallback = dismissalCallbackWithSnoozeAction;
[self.rootViewController.view addSubview:gestureIPHView];
AddSameConstraints(gestureIPHView, contentAreaGuide);
return gestureIPHView;
}
return nil;
}
- (void)featureDismissed:(const base::Feature&)feature
withSnooze:
(feature_engagement::Tracker::SnoozeAction)snoozeAction {
if (!self.engagementTracker) {
return;
}
self.engagementTracker->DismissedWithSnooze(feature, snoozeAction);
}
// Returns YES if the bubble for `feature` has a long duration.
- (BOOL)isLongDurationBubble:(const base::Feature&)feature {
// Display follow iph bubble with long duration.
return feature.name ==
feature_engagement::kIPHFollowWhileBrowsingFeature.name;
}
// 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;
}
@end