chromium/ios/chrome/browser/follow/model/follow_browser_agent.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/follow/model/follow_browser_agent.h"

#import "base/check.h"
#import "base/functional/bind.h"
#import "base/functional/callback.h"
#import "base/metrics/histogram_functions.h"
#import "base/strings/sys_string_conversions.h"
#import "base/task/sequenced_task_runner.h"
#import "base/time/time.h"
#import "components/feed/core/shared_prefs/pref_names.h"
#import "components/prefs/pref_service.h"
#import "ios/chrome/browser/discover_feed/model/discover_feed_service.h"
#import "ios/chrome/browser/discover_feed/model/discover_feed_service_factory.h"
#import "ios/chrome/browser/follow/model/follow_service.h"
#import "ios/chrome/browser/follow/model/follow_service_factory.h"
#import "ios/chrome/browser/follow/model/web_page_urls.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_constants.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_recorder.h"
#import "ios/chrome/browser/shared/model/browser/browser.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/public/commands/feed_commands.h"
#import "ios/chrome/browser/shared/public/commands/new_tab_page_commands.h"
#import "ios/chrome/browser/shared/public/commands/snackbar_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util_mac.h"

namespace {

// Maximum number of times the First Follow UI must be shown.
constexpr int kFirstFollowModalShownMaxCount = 3;

// Old deprecated key used to store how many time the First Follow UI
// has been displayed in NSUserDefaults. Needs to be removed when the
// migration code in `ShouldShowFirstFollowUI()` is removed.
NSString* const kDisplayedFirstFollowModalCountKey =
    @"DisplayedFirstFollowModalCount";

// Time delay in showing and announcing the notification after a site is
// followed/unfollowed from follow feed management.
const base::TimeDelta kSnackbarMessageVoiceOverDelay = base::Seconds(0.8);

// Returns whether the First Follow UI must be displayed.
bool ShouldShowFirstFollowUI(PrefService* pref_service) {
  // Migrate the old preference from NSUserDefaults if it exists. This code
  // needs to be removed in version M-120.
  NSUserDefaults* user_defaults = [NSUserDefaults standardUserDefaults];
  if ([user_defaults objectForKey:kDisplayedFirstFollowModalCountKey]) {
    const NSUInteger count =
        [user_defaults integerForKey:kDisplayedFirstFollowModalCountKey];

    pref_service->SetInteger(prefs::kFirstFollowUIShownCount, count);
    [user_defaults removeObjectForKey:kDisplayedFirstFollowModalCountKey];
  }

  if (experimental_flags::ShouldAlwaysShowFirstFollow()) {
    return true;
  }

  if (experimental_flags::ShouldResetFirstFollowCount()) {
    pref_service->ClearPref(prefs::kFirstFollowUIShownCount);
    pref_service->ClearPref(prefs::kFirstFollowUpdateUIShownCount);
    experimental_flags::DidResetFirstFollowCount();
  }

  const int count =
      IsFollowUIUpdateEnabled()
          ? pref_service->GetInteger(prefs::kFirstFollowUpdateUIShownCount)
          : pref_service->GetInteger(prefs::kFirstFollowUIShownCount);

  return count < kFirstFollowModalShownMaxCount;
}

// Returns whether the source is from a menu action.
bool IsFollowSourceFromMenu(FollowSource source) {
  switch (source) {
    case FollowSource::OverflowMenu:
    case FollowSource::PopupMenu:
      return true;

    case FollowSource::Management:
    case FollowSource::Retry:
    case FollowSource::Undo:
      return false;
  }
}

}  // namespace

BROWSER_USER_DATA_KEY_IMPL(FollowBrowserAgent)

FollowBrowserAgent::~FollowBrowserAgent() = default;

bool FollowBrowserAgent::IsWebSiteFollowed(WebPageURLs* web_page_urls) {
  return GetFollowService()->IsWebSiteFollowed(web_page_urls);
}

NSURL* FollowBrowserAgent::GetRecommendedSiteURL(WebPageURLs* web_page_urls) {
  return GetFollowService()->GetRecommendedSiteURL(web_page_urls);
}

NSArray<FollowedWebSite*>* FollowBrowserAgent::GetFollowedWebSites() {
  return GetFollowService()->GetFollowedWebSites();
}

void FollowBrowserAgent::LoadFollowedWebSites() {
  return GetFollowService()->LoadFollowedWebSites();
}

void FollowBrowserAgent::FollowWebSite(WebPageURLs* web_page_urls,
                                       FollowSource source) {
  // Record if the source is from a menu.
  if (IsFollowSourceFromMenu(source)) {
    [GetMetricsRecorder() recordFollowFromMenu];
    [GetMetricsRecorder()
        recordFollowRequestedWithType:FollowRequestType::kFollowRequestFollow];
  }

  GetFollowService()->FollowWebSite(
      web_page_urls, source,
      base::BindOnce(&FollowBrowserAgent::OnFollowResponse, AsWeakPtr(),
                     web_page_urls, source));
}

void FollowBrowserAgent::UnfollowWebSite(WebPageURLs* web_page_urls,
                                         FollowSource source) {
  // Record if the source is from a menu.
  if (IsFollowSourceFromMenu(source)) {
    [GetMetricsRecorder() recordUnfollowFromMenu];
    [GetMetricsRecorder() recordFollowRequestedWithType:
                              FollowRequestType::kFollowRequestUnfollow];
  }

  GetFollowService()->UnfollowWebSite(
      web_page_urls, source,
      base::BindOnce(&FollowBrowserAgent::OnUnfollowResponse, AsWeakPtr(),
                     web_page_urls, source));
}

void FollowBrowserAgent::SetUIProviders(
    id<NewTabPageCommands> new_tab_page_commands,
    id<SnackbarCommands> snack_bar_commands,
    id<FeedCommands> feed_commands) {
  new_tab_page_commands_ = new_tab_page_commands;
  snack_bar_commands_ = snack_bar_commands;
  feed_commands_ = feed_commands;
}

void FollowBrowserAgent::ClearUIProviders() {
  new_tab_page_commands_ = nil;
  snack_bar_commands_ = nil;
  feed_commands_ = nil;
}

void FollowBrowserAgent::AddObserver(Observer* observer) {
  GetFollowService()->AddObserver(observer);
}

void FollowBrowserAgent::RemoveObserver(Observer* observer) {
  GetFollowService()->RemoveObserver(observer);
}

base::WeakPtr<FollowBrowserAgent> FollowBrowserAgent::AsWeakPtr() {
  return weak_ptr_factory_.GetWeakPtr();
}

FollowBrowserAgent::FollowBrowserAgent(Browser* browser) : browser_(browser) {}

void FollowBrowserAgent::ShowOverlayMessage(FollowSource source,
                                            NSString* message,
                                            NSString* button_text,
                                            MessageBlock message_action,
                                            CompletionBlock completion_action) {
  base::WeakPtr<FollowBrowserAgent> weak_ptr = AsWeakPtr();
  base::OnceClosure closure =
      base::BindOnce(&FollowBrowserAgent::ShowOverlayMessageHelper, weak_ptr,
                     message, button_text, message_action, completion_action);

  // Delay showing the snackbar message when voice over is on and the user has
  // followed/unfollowed the site through feed management. This is to avoid the
  // announcement being cut off by the addition of a new row to the feed
  // management table.
  // TODO(crbug.com/40249735): Temporary solution. A permanent solution should
  // be in place to make sure that the agent verifies that the feed management
  // UI is updated before showing the snackbar message.
  if (UIAccessibilityIsVoiceOverRunning() &&
      source == FollowSource::Management) {
    base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
        FROM_HERE, std::move(closure), kSnackbarMessageVoiceOverDelay);
    return;
  }

  std::move(closure).Run();
}

void FollowBrowserAgent::ShowOverlayMessageHelper(
    NSString* message,
    NSString* button_text,
    MessageBlock message_action,
    CompletionBlock completion_action) {
  [snack_bar_commands_ showSnackbarWithMessage:message
                                    buttonText:button_text
                                 messageAction:message_action
                              completionAction:completion_action];
}

void FollowBrowserAgent::OnFollowResponse(WebPageURLs* web_page_urls,
                                          FollowSource source,
                                          FollowResult result,
                                          FollowedWebSite* web_site) {
  switch (result) {
    case FollowResult::Success:
      OnFollowSuccess(web_page_urls, source, web_site);
      break;

    case FollowResult::Failure:
      OnFollowFailure(web_page_urls, source, web_site);
      break;
  }
}

void FollowBrowserAgent::OnUnfollowResponse(WebPageURLs* web_page_urls,
                                            FollowSource source,
                                            FollowResult result,
                                            FollowedWebSite* web_site) {
  switch (result) {
    case FollowResult::Success:
      OnUnfollowSuccess(web_page_urls, source, web_site);
      break;

    case FollowResult::Failure:
      OnUnfollowFailure(web_page_urls, source, web_site);
      break;
  }
}

void FollowBrowserAgent::OnFollowSuccess(WebPageURLs* web_page_urls,
                                         FollowSource source,
                                         FollowedWebSite* web_site) {
  // Record if the source is from a menu.
  if (IsFollowSourceFromMenu(source)) {
    const NSUInteger count = GetFollowService()->GetFollowedWebSites().count;
    [GetMetricsRecorder() recordFollowCount:count
                               forLogReason:FollowCountLogReasonAfterFollow];
  }

  base::UmaHistogramBoolean(
      "ContentSuggestions.Feed.WebFeed.NewFollow.IsRecommended",
      GetFollowService()->GetRecommendedSiteURL(web_page_urls) ? 1 : 0);

  // Enable the feed prefs to show the feed and to expand it if they
  // are disabled.
  PrefService* const pref_service = browser_->GetBrowserState()->GetPrefs();
  if (!pref_service->GetBoolean(prefs::kArticlesForYouEnabled))
    pref_service->SetBoolean(prefs::kArticlesForYouEnabled, true);

  if (!pref_service->GetBoolean(feed::prefs::kArticlesListVisible))
    pref_service->SetBoolean(feed::prefs::kArticlesListVisible, true);

  // Display the First Follow modal UI if needed.
  const bool is_overflow_menu_source = source == FollowSource::OverflowMenu;
  if (is_overflow_menu_source && ShouldShowFirstFollowUI(pref_service)) {
    if (IsFollowUIUpdateEnabled()) {
      pref_service->SetInteger(
          prefs::kFirstFollowUIShownCount,
          pref_service->GetInteger(prefs::kFirstFollowUpdateUIShownCount) + 1);
    } else {
      pref_service->SetInteger(
          prefs::kFirstFollowUIShownCount,
          pref_service->GetInteger(prefs::kFirstFollowUIShownCount) + 1);
    }

    [feed_commands_ showFirstFollowUIForWebSite:web_site];
    return;
  }

  NSString* message =
      l10n_util::GetNSStringF(IDS_IOS_SNACKBAR_MESSAGE_FOLLOW_SUCCEED,
                              base::SysNSStringToUTF16(web_site.title));

  NSString* button_text =
      l10n_util::GetNSString(IDS_IOS_SNACKBAR_ACTION_GO_TO_FEED);

  __weak FeedMetricsRecorder* metrics_recorder = GetMetricsRecorder();
  __weak id<NewTabPageCommands> new_tab_page_command = new_tab_page_commands_;

  auto message_action = ^{
    [new_tab_page_command openNTPScrolledIntoFeedType:FeedTypeFollowing];
    [metrics_recorder recordFollowSnackbarTappedWithAction:
                          FollowSnackbarActionType::kSnackbarActionGoToFeed];
  };

  auto completion_action = ^(BOOL success) {
    if (success) {
      [metrics_recorder
          recordFollowConfirmationShownWithType:
              FollowConfirmationType::kFollowSucceedSnackbarShown];
    }
  };
  ShowOverlayMessage(source, message, button_text, message_action,
                     completion_action);
}

void FollowBrowserAgent::OnFollowFailure(WebPageURLs* web_page_urls,
                                         FollowSource source,
                                         FollowedWebSite* web_site) {
  NSString* message =
      l10n_util::GetNSString(IDS_IOS_SNACKBAR_MESSAGE_FOLLOW_FAILED);

  NSString* button_text =
      l10n_util::GetNSString(IDS_IOS_SNACKBAR_ACTION_TRY_AGAIN);

  __weak FeedMetricsRecorder* metrics_recorder = GetMetricsRecorder();
  base::WeakPtr<FollowBrowserAgent> weak_ptr = AsWeakPtr();

  auto message_action = ^{
    [metrics_recorder recordFollowSnackbarTappedWithAction:
                          FollowSnackbarActionType::kSnackbarActionRetryFollow];

    // Retry following the website.
    if (weak_ptr)
      weak_ptr->FollowWebSite(web_page_urls, FollowSource::Retry);
  };

  auto completion_action = ^(BOOL success) {
    if (success) {
      [metrics_recorder recordFollowConfirmationShownWithType:
                            FollowConfirmationType::kFollowErrorSnackbarShown];
    }
  };
  ShowOverlayMessage(source, message, button_text, message_action,
                     completion_action);
}

void FollowBrowserAgent::OnUnfollowSuccess(WebPageURLs* web_page_urls,
                                           FollowSource source,
                                           FollowedWebSite* web_site) {
  // Record if the source is from a menu.
  if (IsFollowSourceFromMenu(source)) {
    const NSUInteger count = GetFollowService()->GetFollowedWebSites().count;
    [GetMetricsRecorder() recordFollowCount:count
                               forLogReason:FollowCountLogReasonAfterUnfollow];
  }

  NSString* message =
      l10n_util::GetNSStringF(IDS_IOS_SNACKBAR_MESSAGE_UNFOLLOW_SUCCEED,
                              base::SysNSStringToUTF16(web_site.title));

  NSString* button_text = l10n_util::GetNSString(IDS_IOS_SNACKBAR_ACTION_UNDO);

  __weak FeedMetricsRecorder* metrics_recorder = GetMetricsRecorder();
  base::WeakPtr<FollowBrowserAgent> weak_ptr = AsWeakPtr();

  auto message_action = ^{
    [metrics_recorder recordFollowSnackbarTappedWithAction:
                          FollowSnackbarActionType::kSnackbarActionUndo];

    // Undo unfollowing the website.
    if (weak_ptr)
      weak_ptr->FollowWebSite(web_page_urls, FollowSource::Undo);
  };

  auto completion_action = ^(BOOL success) {
    if (success) {
      [metrics_recorder
          recordFollowConfirmationShownWithType:
              FollowConfirmationType::kUnfollowSucceedSnackbarShown];
    }
  };
  ShowOverlayMessage(source, message, button_text, message_action,
                     completion_action);
}

void FollowBrowserAgent::OnUnfollowFailure(WebPageURLs* web_page_urls,
                                           FollowSource source,
                                           FollowedWebSite* web_site) {
  NSString* message =
      l10n_util::GetNSString(IDS_IOS_SNACKBAR_MESSAGE_UNFOLLOW_FAILED);

  NSString* button_text =
      l10n_util::GetNSString(IDS_IOS_SNACKBAR_ACTION_TRY_AGAIN);

  __weak FeedMetricsRecorder* metrics_recorder = GetMetricsRecorder();
  base::WeakPtr<FollowBrowserAgent> weak_ptr = AsWeakPtr();

  auto message_action = ^{
    [metrics_recorder
        recordFollowSnackbarTappedWithAction:FollowSnackbarActionType::
                                                 kSnackbarActionRetryUnfollow];

    // Retry unfollowing the website.
    if (weak_ptr)
      weak_ptr->UnfollowWebSite(web_page_urls, FollowSource::Retry);
  };

  auto completion_action = ^(BOOL success) {
    if (success) {
      [metrics_recorder
          recordFollowConfirmationShownWithType:
              FollowConfirmationType::kUnfollowErrorSnackbarShown];
    }
  };
  ShowOverlayMessage(source, message, button_text, message_action,
                     completion_action);
}

raw_ptr<FollowService> FollowBrowserAgent::GetFollowService() {
  if (!service_) {
    ChromeBrowserState* browser_state = browser_->GetBrowserState();
    service_ = FollowServiceFactory::GetForBrowserState(browser_state);
    DCHECK(service_);
  }
  return service_;
}

FeedMetricsRecorder* FollowBrowserAgent::GetMetricsRecorder() {
  if (!metrics_recorder_) {
    ChromeBrowserState* browser_state = browser_->GetBrowserState();
    metrics_recorder_ =
        DiscoverFeedServiceFactory::GetForBrowserState(browser_state)
            ->GetFeedMetricsRecorder();
  }
  return metrics_recorder_;
}