| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/ui/views/user_education/feature_promo_controller_views.h" |
| |
| #include <utility> |
| |
| #include "base/feature_list.h" |
| #include "base/logging.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/token.h" |
| #include "chrome/browser/feature_engagement/tracker_factory.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ui/user_education/feature_promo_bubble_params.h" |
| #include "chrome/browser/ui/user_education/feature_promo_snooze_service.h" |
| #include "chrome/browser/ui/user_education/feature_promo_text_replacements.h" |
| #include "chrome/browser/ui/views/chrome_view_class_properties.h" |
| #include "chrome/browser/ui/views/frame/browser_view.h" |
| #include "chrome/browser/ui/views/user_education/feature_promo_bubble_owner.h" |
| #include "chrome/browser/ui/views/user_education/feature_promo_bubble_view.h" |
| #include "chrome/browser/ui/views/user_education/feature_promo_registry.h" |
| #include "chrome/grit/generated_resources.h" |
| #include "components/feature_engagement/public/feature_constants.h" |
| #include "components/feature_engagement/public/tracker.h" |
| #include "third_party/abseil-cpp/absl/types/optional.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/views/bubble/bubble_border.h" |
| #include "ui/views/style/platform_style.h" |
| #include "ui/views/view.h" |
| |
| namespace { |
| |
| views::BubbleBorder::Arrow MapToBubbleBorderArrow( |
| FeaturePromoBubbleParams::Arrow arrow) { |
| using Arrow = FeaturePromoBubbleParams::Arrow; |
| switch (arrow) { |
| case Arrow::TOP_LEFT: |
| return views::BubbleBorder::Arrow::TOP_LEFT; |
| case Arrow::TOP_RIGHT: |
| return views::BubbleBorder::Arrow::TOP_RIGHT; |
| case Arrow::BOTTOM_LEFT: |
| return views::BubbleBorder::Arrow::BOTTOM_LEFT; |
| case Arrow::BOTTOM_RIGHT: |
| return views::BubbleBorder::Arrow::BOTTOM_RIGHT; |
| case Arrow::LEFT_TOP: |
| return views::BubbleBorder::Arrow::LEFT_TOP; |
| case Arrow::RIGHT_TOP: |
| return views::BubbleBorder::Arrow::RIGHT_TOP; |
| case Arrow::LEFT_BOTTOM: |
| return views::BubbleBorder::Arrow::LEFT_BOTTOM; |
| case Arrow::RIGHT_BOTTOM: |
| return views::BubbleBorder::Arrow::RIGHT_BOTTOM; |
| case Arrow::TOP_CENTER: |
| return views::BubbleBorder::Arrow::TOP_CENTER; |
| case Arrow::BOTTOM_CENTER: |
| return views::BubbleBorder::Arrow::BOTTOM_CENTER; |
| case Arrow::LEFT_CENTER: |
| return views::BubbleBorder::Arrow::LEFT_CENTER; |
| case Arrow::RIGHT_CENTER: |
| return views::BubbleBorder::Arrow::RIGHT_CENTER; |
| } |
| } |
| |
| } // namespace |
| |
| // static |
| bool FeaturePromoControllerViews::active_window_check_blocked_for_testing = |
| false; |
| |
| FeaturePromoControllerViews::FeaturePromoControllerViews( |
| BrowserView* browser_view, |
| FeaturePromoBubbleOwner* bubble_owner) |
| : browser_view_(browser_view), |
| bubble_owner_(bubble_owner), |
| snooze_service_(std::make_unique<FeaturePromoSnoozeService>( |
| browser_view->browser()->profile())), |
| tracker_(feature_engagement::TrackerFactory::GetForBrowserContext( |
| browser_view->browser()->profile())) { |
| DCHECK(tracker_); |
| } |
| |
| FeaturePromoControllerViews::~FeaturePromoControllerViews() { |
| if (!bubble_id_) { |
| DCHECK_EQ(current_iph_feature_, nullptr); |
| return; |
| } |
| |
| DCHECK(current_iph_feature_); |
| bubble_owner_->CloseBubble(*bubble_id_); |
| } |
| |
| // static |
| FeaturePromoControllerViews* FeaturePromoControllerViews::GetForView( |
| views::View* view) { |
| views::Widget* widget = view->GetWidget(); |
| if (!widget) |
| return nullptr; |
| |
| BrowserView* browser_view = |
| BrowserView::GetBrowserViewForNativeWindow(widget->GetNativeWindow()); |
| if (!browser_view) |
| return nullptr; |
| |
| return browser_view->feature_promo_controller(); |
| } |
| |
| bool FeaturePromoControllerViews::MaybeShowPromoWithParams( |
| const base::Feature& iph_feature, |
| const FeaturePromoBubbleParams& params, |
| views::View* anchor_view, |
| BubbleCloseCallback close_callback) { |
| return MaybeShowPromoImpl(iph_feature, params, anchor_view, |
| std::move(close_callback)); |
| } |
| |
| absl::optional<base::Token> FeaturePromoControllerViews::ShowCriticalPromo( |
| const FeaturePromoBubbleParams& params, |
| views::View* anchor_view) { |
| if (promos_blocked_for_testing_) |
| return absl::nullopt; |
| |
| // Don't preempt an existing critical promo. |
| if (current_critical_promo_) |
| return absl::nullopt; |
| |
| // If a normal bubble is showing, close it. If the promo is has |
| // continued after a CloseBubbleAndContinuePromo() call, we can't stop |
| // it. However we will show the critical promo anyway. |
| if (current_iph_feature_ && bubble_id_) |
| CloseBubble(*current_iph_feature_); |
| |
| // Snooze is not supported for critical promos. |
| DCHECK(!params.allow_snooze); |
| |
| DCHECK(!bubble_id_); |
| |
| current_critical_promo_ = base::Token::CreateRandom(); |
| ShowPromoBubbleImpl(params, anchor_view); |
| |
| return current_critical_promo_; |
| } |
| |
| void FeaturePromoControllerViews::CloseBubbleForCriticalPromo( |
| const base::Token& critical_promo_id) { |
| if (current_critical_promo_ != critical_promo_id) |
| return; |
| |
| DCHECK(bubble_id_); |
| bubble_owner_->CloseBubble(*bubble_id_); |
| } |
| |
| bool FeaturePromoControllerViews::CriticalPromoIsShowing( |
| const base::Token& critical_promo_id) const { |
| return bubble_id_ && (current_critical_promo_ == critical_promo_id); |
| } |
| |
| bool FeaturePromoControllerViews::DismissNonCriticalBubbleInRegion( |
| const gfx::Rect& screen_bounds) { |
| if (!bubble_id_ || current_critical_promo_ || |
| !bubble_owner_->BubbleIsShowing(bubble_id_.value())) { |
| return false; |
| } |
| |
| if (!screen_bounds.Intersects( |
| bubble_owner_->GetBubbleBoundsInScreen(bubble_id_.value()))) { |
| return false; |
| } |
| |
| bubble_owner_->CloseBubble(bubble_id_.value()); |
| return true; |
| } |
| |
| bool FeaturePromoControllerViews::MaybeShowPromo( |
| const base::Feature& iph_feature, |
| BubbleCloseCallback close_callback) { |
| return MaybeShowPromoWithTextReplacements( |
| iph_feature, FeaturePromoTextReplacements(), std::move(close_callback)); |
| } |
| |
| bool FeaturePromoControllerViews::MaybeShowPromoWithTextReplacements( |
| const base::Feature& iph_feature, |
| FeaturePromoTextReplacements text_replacements, |
| BubbleCloseCallback close_callback) { |
| absl::optional<std::pair<FeaturePromoBubbleParams, views::View*>> params = |
| FeaturePromoRegistry::GetInstance()->GetParamsForFeature(iph_feature, |
| browser_view_); |
| if (!params) |
| return false; |
| |
| DCHECK_GT(params->first.body_string_specifier, -1); |
| params->first.body_text_raw = |
| text_replacements.ApplyTo(params->first.body_string_specifier); |
| params->first.body_string_specifier = -1; |
| |
| return MaybeShowPromoImpl(iph_feature, params->first, params->second, |
| std::move(close_callback)); |
| } |
| |
| void FeaturePromoControllerViews::OnUserSnooze( |
| const base::Feature& iph_feature) { |
| snooze_service_->OnUserSnooze(iph_feature); |
| } |
| |
| void FeaturePromoControllerViews::OnUserDismiss( |
| const base::Feature& iph_feature) { |
| snooze_service_->OnUserDismiss(iph_feature); |
| } |
| |
| bool FeaturePromoControllerViews::BubbleIsShowing( |
| const base::Feature& iph_feature) const { |
| return bubble_id_ && current_iph_feature_ == &iph_feature; |
| } |
| |
| bool FeaturePromoControllerViews::CloseBubble( |
| const base::Feature& iph_feature) { |
| if (!BubbleIsShowing(iph_feature)) |
| return false; |
| bubble_owner_->CloseBubble(*bubble_id_); |
| return true; |
| } |
| |
| void FeaturePromoControllerViews::UpdateBubbleForAnchorBoundsChange() { |
| bubble_owner_->NotifyAnchorBoundsChanged(); |
| } |
| |
| FeaturePromoController::PromoHandle |
| FeaturePromoControllerViews::CloseBubbleAndContinuePromo( |
| const base::Feature& iph_feature) { |
| DCHECK_EQ(&iph_feature, current_iph_feature_); |
| DCHECK(bubble_id_); |
| |
| bubble_owner_->CloseBubble(*std::exchange(bubble_id_, absl::nullopt)); |
| |
| if (anchor_view_tracker_.view()) |
| anchor_view_tracker_.view()->SetProperty(kHasInProductHelpPromoKey, false); |
| |
| if (close_callback_) |
| std::move(close_callback_).Run(); |
| |
| // Record count of previous snoozes when the IPH gets dismissed by user |
| // following the promo. e.g. clicking on relevant controls. |
| int snooze_count = snooze_service_->GetSnoozeCount(iph_feature); |
| base::UmaHistogramExactLinear("InProductHelp.Promos.SnoozeCountAtFollow." + |
| std::string(iph_feature.name), |
| snooze_count, |
| snooze_service_->kUmaMaxSnoozeCount); |
| |
| return PromoHandle(weak_ptr_factory_.GetWeakPtr()); |
| } |
| |
| // static |
| void FeaturePromoControllerViews::BlockActiveWindowCheckForTesting() { |
| active_window_check_blocked_for_testing = true; |
| } |
| |
| // static |
| bool FeaturePromoControllerViews::IsActiveWindowCheckBlockedForTesting() { |
| return active_window_check_blocked_for_testing; |
| } |
| |
| void FeaturePromoControllerViews::BlockPromosForTesting() { |
| promos_blocked_for_testing_ = true; |
| |
| // If we own a bubble, stop the current promo. |
| if (bubble_id_) |
| CloseBubble(*current_iph_feature_); |
| } |
| |
| bool FeaturePromoControllerViews::MaybeShowPromoImpl( |
| const base::Feature& iph_feature, |
| const FeaturePromoBubbleParams& params, |
| views::View* anchor_view, |
| BubbleCloseCallback close_callback) { |
| if (promos_blocked_for_testing_) |
| return false; |
| |
| // A normal promo cannot show if a critical promo is displayed. These |
| // are not registered with |tracker_| so check here. |
| if (current_critical_promo_) |
| return false; |
| |
| // Temporarily turn off IPH in incognito as a concern was raised that |
| // the IPH backend ignores incognito and writes to the parent profile. |
| // See https://bugs.chromium.org/p/chromium/issues/detail?id=1128728#c30 |
| if (browser_view_->GetProfile()->IsIncognitoProfile()) |
| return false; |
| |
| // Don't show IPH if the anchor view is in an inactive window |
| if (!active_window_check_blocked_for_testing && |
| !anchor_view->GetWidget()->ShouldPaintAsActive()) |
| return false; |
| |
| // Some checks should not be done in demo mode, because we absolutely want to |
| // trigger the bubble if possible. Put any checks that should be bypassed in |
| // demo mode in this block. |
| if (!base::FeatureList::IsEnabled(feature_engagement::kIPHDemoMode)) { |
| if (snooze_service_->IsBlocked(iph_feature)) |
| return false; |
| } |
| |
| // If another bubble is showing through `bubble_owner_` it will not show ours. |
| // In this case, don't query `tracker_`. |
| if (bubble_owner_->AnyBubbleIsShowing()) |
| return false; |
| |
| if (!tracker_->ShouldTriggerHelpUI(iph_feature)) |
| return false; |
| |
| // If the tracker says we should trigger, but we have a promo |
| // currently showing, there is a bug somewhere in here. |
| DCHECK(!current_iph_feature_); |
| current_iph_feature_ = &iph_feature; |
| |
| if (!ShowPromoBubbleImpl(params, anchor_view)) { |
| // `current_iph_feature_` is needed in the call. If it fails, we must reset |
| // it and also notify the backend. |
| current_iph_feature_ = nullptr; |
| tracker_->Dismissed(iph_feature); |
| return false; |
| } |
| close_callback_ = std::move(close_callback); |
| |
| // Record count of previous snoozes when an IPH triggers. |
| int snooze_count = snooze_service_->GetSnoozeCount(iph_feature); |
| base::UmaHistogramExactLinear("InProductHelp.Promos.SnoozeCountAtTrigger." + |
| std::string(iph_feature.name), |
| snooze_count, |
| snooze_service_->kUmaMaxSnoozeCount); |
| |
| snooze_service_->OnPromoShown(iph_feature); |
| return true; |
| } |
| |
| void FeaturePromoControllerViews::FinishContinuedPromo() { |
| DCHECK(current_iph_feature_); |
| DCHECK(!bubble_id_); |
| tracker_->Dismissed(*current_iph_feature_); |
| current_iph_feature_ = nullptr; |
| } |
| |
| FeaturePromoBubbleView::CreateParams |
| FeaturePromoControllerViews::GetBaseCreateParams( |
| const FeaturePromoBubbleParams& params, |
| views::View* anchor_view) { |
| // Map |params| to the bubble's create params, fetching needed strings. |
| FeaturePromoBubbleView::CreateParams create_params; |
| create_params.anchor_view = anchor_view; |
| create_params.body_text = |
| params.body_string_specifier != -1 |
| ? l10n_util::GetStringUTF16(params.body_string_specifier) |
| : params.body_text_raw; |
| if (params.title_string_specifier) |
| create_params.title_text = |
| l10n_util::GetStringUTF16(*params.title_string_specifier); |
| |
| if (params.screenreader_string_specifier && params.feature_accelerator) { |
| create_params.screenreader_text = l10n_util::GetStringFUTF16( |
| *params.screenreader_string_specifier, |
| params.feature_accelerator->GetShortcutText()); |
| } else if (params.screenreader_string_specifier) { |
| create_params.screenreader_text = |
| l10n_util::GetStringUTF16(*params.screenreader_string_specifier); |
| } |
| |
| create_params.body_icon = params.body_icon; |
| |
| create_params.focus_on_create = params.focus_on_create; |
| create_params.persist_on_blur = params.persist_on_blur; |
| |
| create_params.arrow = MapToBubbleBorderArrow(params.arrow); |
| create_params.preferred_width = params.preferred_width; |
| |
| if (params.allow_snooze) { |
| create_params.timeout_no_interaction = |
| params.timeout_no_interaction ? params.timeout_no_interaction |
| : snooze_service_->kTimeoutNoInteraction; |
| create_params.timeout_after_interaction = |
| params.timeout_after_interaction |
| ? params.timeout_after_interaction |
| : snooze_service_->kTimeoutAfterInteraction; |
| } else { |
| create_params.timeout_no_interaction = params.timeout_no_interaction; |
| create_params.timeout_after_interaction = params.timeout_after_interaction; |
| } |
| |
| return create_params; |
| } |
| |
| bool FeaturePromoControllerViews::ShowPromoBubbleImpl( |
| const FeaturePromoBubbleParams& params, |
| views::View* anchor_view) { |
| FeaturePromoBubbleView::CreateParams create_params = |
| GetBaseCreateParams(params, anchor_view); |
| |
| if (params.allow_snooze) { |
| FeaturePromoBubbleView::ButtonParams snooze_button; |
| snooze_button.text = l10n_util::GetStringUTF16(IDS_PROMO_SNOOZE_BUTTON); |
| snooze_button.has_border = false; |
| snooze_button.callback = base::BindRepeating( |
| &FeaturePromoControllerViews::OnUserSnooze, |
| weak_ptr_factory_.GetWeakPtr(), *current_iph_feature_); |
| create_params.buttons.push_back(std::move(snooze_button)); |
| |
| FeaturePromoBubbleView::ButtonParams dismiss_button; |
| dismiss_button.text = l10n_util::GetStringUTF16(IDS_PROMO_DISMISS_BUTTON); |
| dismiss_button.has_border = true; |
| dismiss_button.callback = base::BindRepeating( |
| &FeaturePromoControllerViews::OnUserDismiss, |
| weak_ptr_factory_.GetWeakPtr(), *current_iph_feature_); |
| create_params.buttons.push_back(std::move(dismiss_button)); |
| |
| // Snooze should dismiss the feature promo if it times out. |
| create_params.timeout_callback = base::BindRepeating( |
| &FeaturePromoControllerViews::OnUserDismiss, |
| weak_ptr_factory_.GetWeakPtr(), *current_iph_feature_); |
| |
| if (views::PlatformStyle::kIsOkButtonLeading) |
| std::swap(create_params.buttons[0], create_params.buttons[1]); |
| } |
| |
| if (params.show_close_button) { |
| create_params.has_close_button = true; |
| create_params.dismiss_callback = base::BindRepeating( |
| &FeaturePromoControllerViews::OnUserDismiss, |
| weak_ptr_factory_.GetWeakPtr(), *current_iph_feature_); |
| } |
| |
| bubble_id_ = bubble_owner_->ShowBubble( |
| std::move(create_params), |
| base::BindOnce(&FeaturePromoControllerViews::HandleBubbleClosed, |
| weak_ptr_factory_.GetWeakPtr())); |
| if (!bubble_id_) |
| return false; |
| |
| anchor_view->SetProperty(kHasInProductHelpPromoKey, true); |
| anchor_view_tracker_.SetView(anchor_view); |
| return true; |
| } |
| |
| void FeaturePromoControllerViews::HandleBubbleClosed() { |
| // We receive a callback whenever we close the bubble. However, if we closed |
| // it in CloseBubbleAndContinuePromo, we don't want to run this cleanup yet. |
| // There we clear the ID first. |
| if (!bubble_id_) |
| return; |
| |
| // Exactly one of current_iph_feature_ or current_critical_promo_ should have |
| // a value. |
| DCHECK_NE(current_iph_feature_ != nullptr, |
| current_critical_promo_.has_value()); |
| |
| bubble_id_.reset(); |
| |
| if (anchor_view_tracker_.view()) |
| anchor_view_tracker_.view()->SetProperty(kHasInProductHelpPromoKey, false); |
| |
| if (close_callback_) |
| std::move(close_callback_).Run(); |
| |
| if (current_iph_feature_) { |
| tracker_->Dismissed(*current_iph_feature_); |
| current_iph_feature_ = nullptr; |
| } else { |
| current_critical_promo_.reset(); |
| } |
| } |