blob: 6c3ff4215b99a6346cf570e80ee5510b7fe8d5de [file] [log] [blame]
// 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.
#include "components/user_education/webui/help_bubble_handler.h"
#include <memory>
#include <string>
#include "base/callback_list.h"
#include "base/check.h"
#include "base/containers/contains.h"
#include "base/functional/callback.h"
#include "base/functional/callback_forward.h"
#include "base/memory/ptr_util.h"
#include "base/memory/raw_ptr.h"
#include "base/notreached.h"
#include "base/scoped_observation.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "components/user_education/common/help_bubble.h"
#include "components/user_education/common/help_bubble_params.h"
#include "components/user_education/webui/help_bubble_webui.h"
#include "components/user_education/webui/tracked_element_webui.h"
#include "content/public/browser/render_widget_host.h"
#include "content/public/browser/render_widget_host_observer.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_ui.h"
#include "content/public/browser/web_ui_controller.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/element_tracker.h"
#include "ui/webui/resources/cr_components/help_bubble/help_bubble.mojom-shared.h"
#include "ui/webui/resources/cr_components/help_bubble/help_bubble.mojom.h"
namespace user_education {
namespace {
// Converts help bubble arrow to WebUI bubble position. This is not a complete
// mapping as many HelpBubbleArrow options are not (yet) supported in WebUI.
help_bubble::mojom::HelpBubbleArrowPosition HelpBubbleArrowToPosition(
HelpBubbleArrow arrow) {
switch (arrow) {
case HelpBubbleArrow::kBottomLeft:
return help_bubble::mojom::HelpBubbleArrowPosition::BOTTOM_LEFT;
case HelpBubbleArrow::kBottomCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::BOTTOM_CENTER;
case HelpBubbleArrow::kBottomRight:
return help_bubble::mojom::HelpBubbleArrowPosition::BOTTOM_RIGHT;
case HelpBubbleArrow::kTopLeft:
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_LEFT;
case HelpBubbleArrow::kTopCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_CENTER;
case HelpBubbleArrow::kTopRight:
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_RIGHT;
case HelpBubbleArrow::kLeftTop:
return help_bubble::mojom::HelpBubbleArrowPosition::LEFT_TOP;
case HelpBubbleArrow::kLeftCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::LEFT_CENTER;
case HelpBubbleArrow::kLeftBottom:
return help_bubble::mojom::HelpBubbleArrowPosition::LEFT_BOTTOM;
case HelpBubbleArrow::kRightTop:
return help_bubble::mojom::HelpBubbleArrowPosition::RIGHT_TOP;
case HelpBubbleArrow::kRightCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::RIGHT_CENTER;
case HelpBubbleArrow::kRightBottom:
return help_bubble::mojom::HelpBubbleArrowPosition::RIGHT_BOTTOM;
default:
NOTIMPLEMENTED();
}
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_CENTER;
}
std::string SnakeCaseFromCamelCase(std::string input) {
std::string output;
output.reserve(input.size());
for (const char c : input) {
if (std::isupper(c) && !output.empty())
output.push_back('_');
output.push_back(std::tolower(c));
}
return output;
}
// Retrieve the file name from the generated gfx::VectorIcon name
// - Remove the 'k' prefix and 'Icon' suffix from gfx::VectorIcon.name
// - The remaining portion of the name is converted from CamelCase to
// snake_case to yield the original file name
std::string GetFileNameFromIcon(const gfx::VectorIcon* icon) {
std::string icon_name = icon->name;
constexpr char kPrefix[] = "k";
constexpr char kSuffix[] = "Icon";
DCHECK(base::StartsWith(icon_name, kPrefix));
DCHECK(base::EndsWith(icon_name, kSuffix));
icon_name.erase(0, strlen(kPrefix));
icon_name.erase(icon_name.length() - strlen(kSuffix));
return SnakeCaseFromCamelCase(icon_name);
}
} // namespace
struct HelpBubbleHandlerBase::ElementData {
ElementData() = default;
~ElementData() = default;
ElementData(ElementData&& other) = default;
ElementData& operator=(ElementData&& other) = default;
bool has_webui_help_bubble() const { return static_cast<bool>(params); }
// This shows whether the element is visible within the WebContents aside from
// the WebContents itself being visible.
bool visible = false;
gfx::RectF last_known_bounds;
std::unique_ptr<TrackedElementWebUI> element;
std::unique_ptr<HelpBubbleParams> params;
raw_ptr<HelpBubbleWebUI> help_bubble = nullptr;
base::CallbackListSubscription external_bubble_subscription;
// This is set to true if we are closing the help bubble as the result of a
// message from the WebUI, rather than a browser-side event. It is used as a
// guard to prevent a loop where we receive a message that the bubble is
// closing and then tell the WebUI to close the bubble in response.
bool closing = false;
};
void HelpBubbleHandlerBase::VisibilityProvider::SetLastKnownVisibility(
absl::optional<bool> visible) {
handler_->OnWebContentsVisibilityChanged(visible);
}
HelpBubbleHandlerBase::HelpBubbleHandlerBase(
std::unique_ptr<ClientProvider> client_provider,
std::unique_ptr<VisibilityProvider> visibility_provider,
const std::vector<ui::ElementIdentifier>& identifiers,
ui::ElementContext context)
: client_provider_(std::move(client_provider)),
visibility_provider_(std::move(visibility_provider)),
context_(context) {
visibility_provider_->set_handler(this);
DCHECK(context_);
for (auto identifier : identifiers) {
DCHECK(identifier);
const auto it = element_data_.emplace(identifier, ElementData());
DCHECK(it.second) << "Duplicate identifier not allowed: " << identifier;
it.first->second.element =
std::make_unique<TrackedElementWebUI>(this, identifier, context);
}
}
HelpBubbleHandlerBase::~HelpBubbleHandlerBase() {
for (auto& [id, data] : element_data_) {
if (data.help_bubble)
data.help_bubble->Close();
}
}
content::WebContents* HelpBubbleHandlerBase::GetWebContents() {
return GetController()->web_ui()->GetWebContents();
}
content::RenderWidgetHost* HelpBubbleHandlerBase::GetRenderWidgetHost() {
auto* const web_contents = GetWebContents();
if (!web_contents) {
return nullptr;
}
auto* const render_widget_host_view = web_contents->GetRenderWidgetHostView();
if (!render_widget_host_view) {
return nullptr;
}
return render_widget_host_view->GetRenderWidgetHost();
}
help_bubble::mojom::HelpBubbleClient* HelpBubbleHandlerBase::GetClient() {
return client_provider_->GetClient();
}
void HelpBubbleHandlerBase::ReportBadMessage(base::StringPiece error) {
NOTREACHED() << error;
}
std::unique_ptr<HelpBubbleWebUI> HelpBubbleHandlerBase::CreateHelpBubble(
ui::ElementIdentifier identifier,
HelpBubbleParams params) {
const auto it = element_data_.find(identifier);
if (it == element_data_.end()) {
NOTREACHED() << "Identifier " << identifier << " was never registered.";
return nullptr;
}
auto& data = it->second;
if (data.has_webui_help_bubble()) {
NOTREACHED() << "A help bubble is already being shown for " << identifier;
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
if (data.help_bubble) {
data.help_bubble->Close();
if (!weak_ptr)
return nullptr;
}
}
data.params = std::make_unique<HelpBubbleParams>(std::move(params));
auto result = base::WrapUnique(new HelpBubbleWebUI(this, identifier));
auto mojom_params = help_bubble::mojom::HelpBubbleParams::New();
mojom_params->native_identifier = identifier.GetName();
mojom_params->body_text = base::UTF16ToUTF8(data.params->body_text);
mojom_params->close_button_alt_text =
base::UTF16ToUTF8(data.params->close_button_alt_text);
auto timeout = data.params->timeout.value_or(
data.params->buttons.empty() ? kDefaultTimeoutWithoutButtons
: kDefaultTimeoutWithButtons);
if (!timeout.is_zero())
mojom_params->timeout = timeout;
if (data.params->body_icon)
mojom_params->body_icon_name = GetFileNameFromIcon(data.params->body_icon);
mojom_params->body_icon_alt_text =
base::UTF16ToUTF8(data.params->body_icon_alt_text);
mojom_params->position = HelpBubbleArrowToPosition(data.params->arrow);
if (data.params->progress) {
mojom_params->progress = help_bubble::mojom::Progress::New();
mojom_params->progress->current = data.params->progress->first;
mojom_params->progress->total = data.params->progress->second;
}
if (!data.params->title_text.empty())
mojom_params->title_text = base::UTF16ToUTF8(data.params->title_text);
for (auto& button : data.params->buttons) {
auto mojom_button = help_bubble::mojom::HelpBubbleButtonParams::New();
mojom_button->text = base::UTF16ToUTF8(button.text);
mojom_button->is_default = button.is_default;
mojom_params->buttons.emplace_back(std::move(mojom_button));
}
GetClient()->ShowHelpBubble(std::move(mojom_params));
it->second.help_bubble = result.get();
return result;
}
void HelpBubbleHandlerBase::OnHelpBubbleClosing(
ui::ElementIdentifier anchor_id) {
const auto it = element_data_.find(anchor_id);
if (it == element_data_.end()) {
NOTREACHED() << "Identifier " << anchor_id << " was never registered.";
return;
}
if (!it->second.closing)
GetClient()->HideHelpBubble(anchor_id.GetName());
it->second.help_bubble = nullptr;
it->second.params.reset();
// If this anchor element was only considered visible because it still had a
// help bubble, hide it.
if (it->second.element->visible() && !is_web_contents_visible()) {
it->second.element->SetVisible(false);
}
}
void HelpBubbleHandlerBase::OnWebContentsVisibilityChanged(
absl::optional<bool> visibility) {
const bool old_visibility = is_web_contents_visible();
web_contents_visibility_ = visibility;
const bool new_visibility = is_web_contents_visible();
if (new_visibility == old_visibility) {
return;
}
// Callbacks during this call may cause almost anything to happen, so make
// sure that we bail if this object is destroyed.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
for (auto& [id, data] : element_data_) {
if (new_visibility && data.visible) {
data.element->SetVisible(true, data.last_known_bounds);
} else if (!new_visibility && data.element->visible()) {
// An embedded help bubble prevents the element from being hidden.
// This usually only happens in WebUI that are hosted in browser tabs.
if (!data.has_webui_help_bubble()) {
data.element->SetVisible(false);
}
}
if (!weak_ptr) {
return;
}
}
}
void HelpBubbleHandlerBase::HelpBubbleAnchorVisibilityChanged(
const std::string& identifier_name,
bool visible,
const gfx::RectF& rect) {
ui::ElementIdentifier id;
ElementData* const data = GetDataByName(identifier_name, &id);
if (!data)
return;
// Only set the bounds if the anchor is visible in the WebContents.
if (visible) {
data->last_known_bounds = rect;
// Also maybe check for the WebContents visibility.
if (!web_contents_visibility_.has_value()) {
web_contents_visibility_ = visibility_provider_->CheckIsVisible();
}
}
// It's possible the element is visible in the WebContents but the WebContents
// itself isn't visible. Save this value in case the two currently do not
// agree with each other.
data->visible = visible;
// An anchor which is currently hosting a WebUI help bubble ignores its
// WebContents' visibility. Otherwise, a hidden WebContents hides its anchors.
if (!data->has_webui_help_bubble()) {
visible = visible && is_web_contents_visible();
}
// Note: any of the following calls could destroy *this* via a callback.
if (visible) {
data->element->SetVisible(true, rect);
} else if (data->element->visible() && !visible) {
// Is a help bubble currently showing?
if (data->has_webui_help_bubble()) {
// Currently, this is the only call that could trigger callbacks and which
// has additional code which executes after it. If that changes, the weak
// pointer can be moved closer to the top of this method.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
HelpBubbleClosed(
identifier_name,
help_bubble::mojom::HelpBubbleClosedReason::kPageChanged);
if (!weak_ptr)
return;
}
data->element->SetVisible(false);
}
}
void HelpBubbleHandlerBase::HelpBubbleAnchorActivated(
const std::string& identifier_name) {
ui::ElementIdentifier id;
ElementData* const data = GetDataByName(identifier_name, &id);
if (!data)
return;
if (!data->element->visible()) {
ReportBadMessage(
base::StringPrintf("HelpBubbleAnchorActivated message received for "
"anchor element \"%s\" but element was not visible.",
identifier_name.c_str()));
return;
}
data->element->Activate();
}
void HelpBubbleHandlerBase::HelpBubbleAnchorCustomEvent(
const std::string& identifier_name,
const std::string& event_name) {
ui::ElementIdentifier id;
ElementData* const data = GetDataByName(identifier_name, &id);
if (!data)
return;
if (!data->element->visible()) {
ReportBadMessage(
base::StringPrintf("HelpBubbleAnchorCustomEvent message received for "
"anchor element \"%s\" but element was not visible.",
identifier_name.c_str()));
return;
}
// Because names of events are lazily loaded the first time someone tries to
// listen for them, the name of a valid event may not be registered. So it's
// okay if this query comes up empty.
const ui::CustomElementEventType event_type =
ui::CustomElementEventType::FromName(event_name.c_str());
if (!event_type)
return;
data->element->CustomEvent(event_type);
}
void HelpBubbleHandlerBase::HelpBubbleButtonPressed(
const std::string& identifier_name,
uint8_t button_index) {
ElementData* const data = GetDataByName(identifier_name);
if (!data)
return;
if (!data->has_webui_help_bubble()) {
ReportBadMessage(
base::StringPrintf("HelpBubbleButtonPressed message received for "
"anchor element \"%s\" but no help bubble was open.",
identifier_name.c_str()));
return;
}
if (button_index >= data->params->buttons.size()) {
ReportBadMessage(base::StringPrintf(
"HelpBubbleButtonPressed received but button index was invalid; "
"got %u but there are only %zu buttons.",
button_index, data->params->buttons.size()));
return;
}
// We can never ensure that objects will persist across callbacks.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
data->closing = true;
base::OnceClosure callback =
std::move(data->params->buttons[button_index].callback);
if (callback)
std::move(callback).Run();
if (!weak_ptr)
return;
if (data->help_bubble)
data->help_bubble->Close();
if (!weak_ptr)
return;
data->closing = false;
}
void HelpBubbleHandlerBase::HelpBubbleClosed(
const std::string& identifier_name,
help_bubble::mojom::HelpBubbleClosedReason reason) {
ElementData* const data = GetDataByName(identifier_name);
if (!data)
return;
if (!data->has_webui_help_bubble()) {
ReportBadMessage(base::StringPrintf(
"HelpBubbleClosed message received for identifier_name = \"%s\" but no "
"help bubble was open.",
identifier_name.c_str()));
return;
}
// We can never ensure that `this` will persist across callbacks.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
data->closing = true;
base::OnceClosure callback;
switch (reason) {
case help_bubble::mojom::HelpBubbleClosedReason::kDismissedByUser:
callback = std::move(data->params->dismiss_callback);
break;
case help_bubble::mojom::HelpBubbleClosedReason::kTimedOut:
callback = std::move(data->params->timeout_callback);
break;
case help_bubble::mojom::HelpBubbleClosedReason::kPageChanged:
break;
}
if (callback) {
std::move(callback).Run();
if (!weak_ptr)
return;
}
// This could also theoretically trigger callbacks.
if (data->help_bubble) {
data->help_bubble->Close();
}
if (!weak_ptr)
return;
data->closing = false;
}
bool HelpBubbleHandlerBase::ToggleHelpBubbleFocusForAccessibility(
ui::ElementIdentifier anchor_id) {
if (base::Contains(element_data_, anchor_id)) {
GetClient()->ToggleFocusForAccessibility(anchor_id.GetName());
return true;
}
return false;
}
gfx::Rect HelpBubbleHandlerBase::GetHelpBubbleBoundsInScreen(
ui::ElementIdentifier anchor_id) const {
// TODO(dfried): implement.
return gfx::Rect();
}
void HelpBubbleHandlerBase::OnFloatingHelpBubbleCreated(
ui::ElementIdentifier anchor_id,
HelpBubble* help_bubble) {
GetClient()->ExternalHelpBubbleUpdated(anchor_id.GetName(), true);
const auto it = element_data_.find(anchor_id);
if (it == element_data_.end()) {
return;
}
DCHECK(!it->second.external_bubble_subscription);
it->second.external_bubble_subscription = help_bubble->AddOnCloseCallback(
base::BindOnce(&HelpBubbleHandlerBase::OnFloatingHelpBubbleClosed,
weak_ptr_factory_.GetWeakPtr(), anchor_id));
}
void HelpBubbleHandlerBase::OnFloatingHelpBubbleClosed(
ui::ElementIdentifier anchor_id,
HelpBubble* help_bubble) {
const auto it = element_data_.find(anchor_id);
if (it == element_data_.end()) {
return;
}
it->second.external_bubble_subscription = base::CallbackListSubscription();
GetClient()->ExternalHelpBubbleUpdated(anchor_id.GetName(), false);
}
HelpBubbleHandlerBase::ElementData* HelpBubbleHandlerBase::GetDataByName(
const std::string& identifier_name,
ui::ElementIdentifier* found_identifier) {
for (auto& [id, data] : element_data_) {
if (id.GetName() == identifier_name) {
if (found_identifier)
*found_identifier = id;
return &data;
}
}
if (found_identifier)
*found_identifier = ui::ElementIdentifier();
ReportBadMessage(base::StringPrintf(
"HelpBubbleHandler IPC message received with unrecognized "
"identifier_name: \"%s\"",
identifier_name.c_str()));
return nullptr;
}
class HelpBubbleHandler::ClientProvider
: public HelpBubbleHandlerBase::ClientProvider {
public:
explicit ClientProvider(
mojo::PendingRemote<help_bubble::mojom::HelpBubbleClient> pending_client)
: remote_client_(std::move(pending_client)) {}
~ClientProvider() override = default;
help_bubble::mojom::HelpBubbleClient* GetClient() override {
return remote_client_.get();
}
private:
mojo::Remote<help_bubble::mojom::HelpBubbleClient> remote_client_;
};
// Implementation of the WebContents visibility tracker. Watches the
// RenderWidgetHost for visibility changes and signals them to its
// HelpBubbleHandler.
class HelpBubbleHandler::VisibilityProvider
: public HelpBubbleHandlerBase::VisibilityProvider,
public content::RenderWidgetHostObserver {
public:
VisibilityProvider() = default;
~VisibilityProvider() override = default;
absl::optional<bool> CheckIsVisible() const override {
auto* const host = handler()->GetRenderWidgetHost();
if (!host) {
return absl::nullopt;
}
CHECK(!observation_.IsObserving());
observation_.Observe(host);
// Current visibility cannot be determined from the host directly, but can
// be read from its view.
auto* const view = host->GetView();
return view && view->IsShowing();
}
private:
// content::RenderWidgetHostObserver:
void RenderWidgetHostVisibilityChanged(content::RenderWidgetHost* host,
bool became_visible) override {
SetLastKnownVisibility(became_visible);
}
void RenderWidgetHostDestroyed(content::RenderWidgetHost*) override {
observation_.Reset();
SetLastKnownVisibility(absl::nullopt);
}
// This observation is created lazily from CheckIsVisible(), so must be
// mutable.
mutable base::ScopedObservation<content::RenderWidgetHost,
content::RenderWidgetHostObserver>
observation_{this};
};
HelpBubbleHandler::HelpBubbleHandler(
mojo::PendingReceiver<help_bubble::mojom::HelpBubbleHandler>
pending_handler,
mojo::PendingRemote<help_bubble::mojom::HelpBubbleClient> pending_client,
content::WebUIController* controller,
const std::vector<ui::ElementIdentifier>& identifiers)
: HelpBubbleHandlerBase(
std::make_unique<ClientProvider>(std::move(pending_client)),
std::make_unique<VisibilityProvider>(),
identifiers,
ui::ElementContext(controller)),
receiver_(this, std::move(pending_handler)),
controller_(controller) {
DCHECK(controller);
}
HelpBubbleHandler::~HelpBubbleHandler() = default;
content::WebUIController* HelpBubbleHandler::GetController() {
return controller_;
}
void HelpBubbleHandler::ReportBadMessage(base::StringPiece error) {
receiver_.ReportBadMessage(std::move(error));
}
} // namespace user_education