chromium/ios/chrome/browser/ui/content_suggestions/cells/most_visited_tiles_mediator.mm

// Copyright 2024 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/content_suggestions/cells/most_visited_tiles_mediator.h"

#import <MaterialComponents/MaterialSnackbar.h>

#import "base/apple/foundation_util.h"
#import "base/ios/ios_util.h"
#import "base/metrics/user_metrics.h"
#import "base/strings/sys_string_conversions.h"
#import "base/values.h"
#import "components/ntp_tiles/features.h"
#import "components/ntp_tiles/metrics.h"
#import "components/ntp_tiles/most_visited_sites.h"
#import "components/ntp_tiles/ntp_tile.h"
#import "components/prefs/pref_service.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/favicon/ui_bundled/favicon_attributes_provider.h"
#import "ios/chrome/browser/net/model/crurl.h"
#import "ios/chrome/browser/ntp/ui_bundled/new_tab_page_metrics_delegate.h"
#import "ios/chrome/browser/ntp_tiles/model/most_visited_sites_observer_bridge.h"
#import "ios/chrome/browser/policy/model/policy_util.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.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/ui/util/snackbar_util.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_item.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_tile_view.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_tile_constants.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_tile_saver.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/most_visited_tiles_config.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/most_visited_tiles_stack_view_consumer.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/most_visited_tiles_stack_view_consumer_source.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_constants.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_consumer.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_delegate.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_menu_provider.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_metrics_recorder.h"
#import "ios/chrome/browser/ui/menu/browser_action_factory.h"
#import "ios/chrome/browser/url_loading/model/url_loading_browser_agent.h"
#import "ios/chrome/browser/url_loading/model/url_loading_params.h"
#import "ios/chrome/common/app_group/app_group_constants.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util_mac.h"

namespace {

// Maximum number of most visited tiles fetched.
const NSInteger kMaxNumMostVisitedTiles = 4;

// Size below which the provider returns a colored tile instead of an image.
const CGFloat kMagicStackMostVisitedFaviconMinimalSize = 18;

}  // namespace

@interface MostVisitedTilesMediator () <MostVisitedSitesObserving,
                                        MostVisitedTilesStackViewConsumerSource,
                                        ContentSuggestionsMenuProvider>
@end

@implementation MostVisitedTilesMediator {
  std::unique_ptr<ntp_tiles::MostVisitedSites> _mostVisitedSites;
  std::unique_ptr<ntp_tiles::MostVisitedSitesObserverBridge> _mostVisitedBridge;
  FaviconAttributesProvider* _mostVisitedAttributesProvider;
  std::map<GURL, FaviconCompletionHandler> _mostVisitedFetchFaviconCallbacks;
  NSMutableArray<ContentSuggestionsMostVisitedItem*>* _freshMostVisitedItems;
  // Most visited items from the MostVisitedSites service currently displayed.
  MostVisitedTilesConfig* _mostVisitedConfig;
  // Whether incognito mode is available.
  BOOL _incognitoAvailable;
  BOOL _recordedPageImpression;
  PrefService* _prefService;
  UrlLoadingBrowserAgent* _URLLoadingBrowserAgent;
  // Consumer of model updates when MVTs are in the Magic Stack.
  id<MostVisitedTilesStackViewConsumer> _stackViewConsumer;
}

- (instancetype)
    initWithMostVisitedSite:
        (std::unique_ptr<ntp_tiles::MostVisitedSites>)mostVisitedSites
                prefService:(PrefService*)prefService
           largeIconService:(favicon::LargeIconService*)largeIconService
             largeIconCache:(LargeIconCache*)largeIconCache
     URLLoadingBrowserAgent:(UrlLoadingBrowserAgent*)URLLoadingBrowserAgent {
  self = [super init];
  if (self) {
    _prefService = prefService;
    _URLLoadingBrowserAgent = URLLoadingBrowserAgent;
    _incognitoAvailable = !IsIncognitoModeDisabled(prefService);
    _mostVisitedAttributesProvider = [[FaviconAttributesProvider alloc]
        initWithFaviconSize:kMagicStackFaviconWidth
             minFaviconSize:kMagicStackMostVisitedFaviconMinimalSize
           largeIconService:largeIconService];
    // Set a cache only for the Most Visited provider, as the cache is
    // overwritten for every new results and the size of the favicon fetched for
    // the suggestions is much smaller.
    _mostVisitedAttributesProvider.cache = largeIconCache;

    _mostVisitedSites = std::move(mostVisitedSites);
    _mostVisitedBridge =
        std::make_unique<ntp_tiles::MostVisitedSitesObserverBridge>(self);
    _mostVisitedSites->AddMostVisitedURLsObserver(_mostVisitedBridge.get(),
                                                  kMaxNumMostVisitedTiles);
  }
  return self;
}

- (void)disconnect {
  _mostVisitedBridge.reset();
  _mostVisitedSites.reset();
  _mostVisitedAttributesProvider = nil;
}

+ (NSUInteger)maxSitesShown {
  return kMaxNumMostVisitedTiles;
}

- (void)refreshMostVisitedTiles {
  // Refresh in case there are new MVT to show.
  _mostVisitedSites->Refresh();
}

- (MostVisitedTilesConfig*)mostVisitedTilesConfig {
  return _mostVisitedConfig;
}

#pragma mark - MostVisitedSitesObserving

- (void)onMostVisitedURLsAvailable:
    (const ntp_tiles::NTPTilesVector&)mostVisited {
  // This is used by the content widget.
  content_suggestions_tile_saver::SaveMostVisitedToDisk(
      mostVisited, _mostVisitedAttributesProvider,
      app_group::ContentWidgetFaviconsFolder());

  _freshMostVisitedItems = [NSMutableArray array];
  int index = 0;
  for (const ntp_tiles::NTPTile& tile : mostVisited) {
    ContentSuggestionsMostVisitedItem* item = [self convertNTPTile:tile];
    item.commandHandler = self;
    item.incognitoAvailable = _incognitoAvailable;
    item.index = index;
    item.menuProvider = self;
    DCHECK(index < kShortcutMinimumIndex);
    index++;
    [_freshMostVisitedItems addObject:item];
  }

  [self useFreshMostVisited];

  if (mostVisited.size() && !_recordedPageImpression) {
    _recordedPageImpression = YES;
    [self recordMostVisitedTilesDisplayed];
    ntp_tiles::metrics::RecordPageImpression(mostVisited.size());
  }
}

- (void)onIconMadeAvailable:(const GURL&)siteURL {
  // This is used by the content widget.
  content_suggestions_tile_saver::UpdateSingleFavicon(
      siteURL, _mostVisitedAttributesProvider,
      app_group::ContentWidgetFaviconsFolder());

  for (ContentSuggestionsMostVisitedItem* item in _mostVisitedConfig
           .mostVisitedItems) {
    if (item.URL == siteURL) {
      FaviconCompletionHandler completion =
          _mostVisitedFetchFaviconCallbacks[siteURL];
      if (completion) {
        [_mostVisitedAttributesProvider
            fetchFaviconAttributesForURL:siteURL
                              completion:completion];
      }
      return;
    }
  }
}

#pragma mark - ContentSuggestionsImageDataSource

- (void)fetchFaviconForURL:(const GURL&)URL
                completion:(FaviconCompletionHandler)completion {
  _mostVisitedFetchFaviconCallbacks[URL] = completion;
  [_mostVisitedAttributesProvider fetchFaviconAttributesForURL:URL
                                                    completion:completion];
}

#pragma mark - MostVisitedTilesCommands

- (void)mostVisitedTileTapped:(UIGestureRecognizer*)sender {
  ContentSuggestionsMostVisitedTileView* mostVisitedView =
      static_cast<ContentSuggestionsMostVisitedTileView*>(sender.view);
  ContentSuggestionsMostVisitedItem* mostVisitedItem =
      base::apple::ObjCCastStrict<ContentSuggestionsMostVisitedItem>(
          mostVisitedView.config);

  [self logMostVisitedOpening:mostVisitedItem atIndex:mostVisitedItem.index];

  UrlLoadParams params = UrlLoadParams::InCurrentTab(mostVisitedItem.URL);
  params.web_params.transition_type = ui::PAGE_TRANSITION_AUTO_BOOKMARK;
  _URLLoadingBrowserAgent->Load(params);
}

- (void)openNewTabWithMostVisitedItem:(ContentSuggestionsMostVisitedItem*)item
                            incognito:(BOOL)incognito
                              atIndex:(NSInteger)index
                            fromPoint:(CGPoint)point {
  if (incognito && IsIncognitoModeDisabled(_prefService)) {
    // This should only happen when the policy changes while the option is
    // presented.
    return;
  }
  [self logMostVisitedOpening:item atIndex:index];
  [self openNewTabWithURL:item.URL incognito:incognito originPoint:point];
}

- (void)openNewTabWithMostVisitedItem:(ContentSuggestionsMostVisitedItem*)item
                            incognito:(BOOL)incognito
                              atIndex:(NSInteger)index {
  if (incognito && IsIncognitoModeDisabled(_prefService)) {
    // This should only happen when the policy changes while the option is
    // presented.
    return;
  }
  [self logMostVisitedOpening:item atIndex:index];
  [self openNewTabWithURL:item.URL incognito:incognito originPoint:CGPointZero];
}

- (void)openNewTabWithMostVisitedItem:(ContentSuggestionsMostVisitedItem*)item
                            incognito:(BOOL)incognito {
  [self openNewTabWithMostVisitedItem:item
                            incognito:incognito
                              atIndex:item.index];
}

- (void)removeMostVisited:(ContentSuggestionsMostVisitedItem*)item {
  [self.contentSuggestionsMetricsRecorder recordMostVisitedTileRemoved];
  [self blockMostVisitedURL:item.URL];
  [self showMostVisitedUndoForURL:item.URL];
}

#pragma mark - ContentSuggestionsMenuProvider

- (UIContextMenuConfiguration*)contextMenuConfigurationForItem:
                                   (ContentSuggestionsMostVisitedItem*)item
                                                      fromView:(UIView*)view {
  __weak __typeof(self) weakSelf = self;

  UIContextMenuActionProvider actionProvider =
      ^(NSArray<UIMenuElement*>* suggestedActions) {
        MostVisitedTilesMediator* strongSelf = weakSelf;
        if (!strongSelf) {
          // Return an empty menu.
          return [UIMenu menuWithTitle:@"" children:@[]];
        }
        return [strongSelf contextMenuActionProviderForItem:item fromView:view];
      };
  return
      [UIContextMenuConfiguration configurationWithIdentifier:nil
                                              previewProvider:nil
                                               actionProvider:actionProvider];
}

#pragma mark - MostVisitedTilesStackViewConsumerSource

- (void)addConsumer:(id<MostVisitedTilesStackViewConsumer>)consumer {
  if (_stackViewConsumer == consumer) {
    return;
  }
  _stackViewConsumer = consumer;
}

#pragma mark - Private

- (UIMenu*)contextMenuActionProviderForItem:
               (ContentSuggestionsMostVisitedItem*)item
                                   fromView:(UIView*)view {
  // Record that this context menu was shown to the user.
  RecordMenuShown(kMenuScenarioHistogramMostVisitedEntry);

  NSMutableArray<UIMenuElement*>* menuElements = [[NSMutableArray alloc] init];

  CGPoint centerPoint = [view.superview convertPoint:view.center toView:nil];

  __weak MostVisitedTilesMediator* weakSelf = self;
  [menuElements addObject:[self.actionFactory actionToOpenInNewTabWithBlock:^{
                  [weakSelf openNewTabWithMostVisitedItem:item
                                                incognito:NO
                                                  atIndex:item.index
                                                fromPoint:centerPoint];
                }]];

  UIAction* incognitoAction =
      [self.actionFactory actionToOpenInNewIncognitoTabWithBlock:^{
        [weakSelf openNewTabWithMostVisitedItem:item
                                      incognito:YES
                                        atIndex:item.index
                                      fromPoint:centerPoint];
      }];

  if (IsIncognitoModeDisabled(_prefService)) {
    // Disable the "Open in Incognito" option if the incognito mode is
    // disabled.
    incognitoAction.attributes = UIMenuElementAttributesDisabled;
  }

  [menuElements addObject:incognitoAction];

  if (base::ios::IsMultipleScenesSupported()) {
    UIAction* newWindowAction = [self.actionFactory
        actionToOpenInNewWindowWithURL:item.URL
                        activityOrigin:WindowActivityContentSuggestionsOrigin];
    [menuElements addObject:newWindowAction];
  }

  CrURL* URL = [[CrURL alloc] initWithGURL:item.URL];
  [menuElements addObject:[self.actionFactory actionToCopyURL:URL]];

  [menuElements addObject:[self.actionFactory actionToShareWithBlock:^{
                  [weakSelf.contentSuggestionsDelegate shareURL:item.URL
                                                          title:item.title
                                                       fromView:view];
                }]];

  [menuElements addObject:[self.actionFactory actionToRemoveWithBlock:^{
                  [weakSelf removeMostVisited:item];
                }]];

  return [UIMenu menuWithTitle:@"" children:menuElements];
}

// Replaces the Most Visited items currently displayed by the most recent ones.
- (void)useFreshMostVisited {
    const base::Value::List& oldMostVisitedSites =
        _prefService->GetList(prefs::kIosLatestMostVisitedSites);
    base::Value::List freshMostVisitedSites;
    for (ContentSuggestionsMostVisitedItem* item in _freshMostVisitedItems) {
      freshMostVisitedSites.Append(item.URL.spec());
    }
    // Don't check for a change in the Most Visited Sites if the device doesn't
    // have any saved sites to begin with. This will not log for users with no
    // top sites that have a new top site, but the benefit of not logging for
    // new installs outweighs it.
    if (!oldMostVisitedSites.empty()) {
      [self lookForNewMostVisitedSite:freshMostVisitedSites
                  oldMostVisitedSites:oldMostVisitedSites];
    }
    _prefService->SetList(prefs::kIosLatestMostVisitedSites,
                          std::move(freshMostVisitedSites));

    _mostVisitedConfig = [[MostVisitedTilesConfig alloc] init];
    _mostVisitedConfig.imageDataSource = self;
    _mostVisitedConfig.commandHandler = self;
    _mostVisitedConfig.mostVisitedItems = _freshMostVisitedItems;
    _mostVisitedConfig.consumerSource = self;
    if (ShouldPutMostVisitedSitesInMagicStack()) {
      if ([_freshMostVisitedItems count] == 0) {
        [self.delegate removeMostVisitedTilesModule];
      } else if (!oldMostVisitedSites.empty()) {
        [_stackViewConsumer updateWithConfig:_mostVisitedConfig];
      } else {
        [self.delegate didReceiveInitialMostVistedTiles];
      }
    } else {
      [self.consumer setMostVisitedTilesConfig:_mostVisitedConfig];
      [self.contentSuggestionsDelegate contentSuggestionsWasUpdated];
    }
}

// Logs a histogram due to a Most Visited item being opened.
- (void)logMostVisitedOpening:(ContentSuggestionsMostVisitedItem*)item
                      atIndex:(NSInteger)mostVisitedIndex {
  [self.NTPMetricsDelegate mostVisitedTileOpened];
  if (ShouldPutMostVisitedSitesInMagicStack()) {
    [self.delegate logMagicStackEngagementForType:ContentSuggestionsModuleType::
                                                      kMostVisited];
  }
  [self.contentSuggestionsMetricsRecorder
      recordMostVisitedTileOpened:item
                          atIndex:mostVisitedIndex];
}

// Opens the `URL` in a new tab `incognito` or not. `originPoint` is the origin
// of the new tab animation if the tab is opened in background, in window
// coordinates.
- (void)openNewTabWithURL:(const GURL&)URL
                incognito:(BOOL)incognito
              originPoint:(CGPoint)originPoint {
  // Open the tab in background if it is non-incognito only.
  UrlLoadParams params = UrlLoadParams::InNewTab(URL);
  params.SetInBackground(!incognito);
  params.in_incognito = incognito;
  params.append_to = OpenPosition::kCurrentTab;
  params.origin_point = originPoint;
  _URLLoadingBrowserAgent->Load(params);
}

- (void)blockMostVisitedURL:(GURL)URL {
  _mostVisitedSites->AddOrRemoveBlockedUrl(URL, true);
  [self useFreshMostVisited];
}

// Shows a snackbar with an action to undo the removal of the most visited item
// with a `URL`.
- (void)showMostVisitedUndoForURL:(GURL)URL {
  MDCSnackbarMessageAction* action = [[MDCSnackbarMessageAction alloc] init];
  __weak MostVisitedTilesMediator* weakSelf = self;
  action.handler = ^{
    [weakSelf allowMostVisitedURL:URL];
  };
  action.title = l10n_util::GetNSString(IDS_NEW_TAB_UNDO_THUMBNAIL_REMOVE);
  action.accessibilityIdentifier = @"Undo";

  TriggerHapticFeedbackForNotification(UINotificationFeedbackTypeSuccess);
  MDCSnackbarMessage* message = CreateSnackbarMessage(
      l10n_util::GetNSString(IDS_IOS_NEW_TAB_MOST_VISITED_ITEM_REMOVED));
  message.action = action;
  message.category = @"MostVisitedUndo";
  [self.snackbarHandler showSnackbarMessage:message];
}

- (void)allowMostVisitedURL:(GURL)URL {
  _mostVisitedSites->AddOrRemoveBlockedUrl(URL, false);
  [self useFreshMostVisited];
}

// Updates `prefs::kIosSyncSegmentsNewTabPageDisplayCount` with the number of
// remaining New Tab Page displays that include synced history in the Most
// Visited Tiles.
- (void)recordMostVisitedTilesDisplayed {
  const int displayCount =
      _prefService->GetInteger(prefs::kIosSyncSegmentsNewTabPageDisplayCount) +
      1;

  _prefService->SetInteger(prefs::kIosSyncSegmentsNewTabPageDisplayCount,
                           displayCount);
}

// Logs a User Action if `freshMostVisitedSites` has at least one site that
// isn't in `oldMostVisitedSites`.
- (void)
    lookForNewMostVisitedSite:(const base::Value::List&)freshMostVisitedSites
          oldMostVisitedSites:(const base::Value::List&)oldMostVisitedSites {
  for (auto const& freshSiteURLValue : freshMostVisitedSites) {
    BOOL freshSiteInOldList = NO;
    for (auto const& oldSiteURLValue : oldMostVisitedSites) {
      if (freshSiteURLValue.GetString() == oldSiteURLValue.GetString()) {
        freshSiteInOldList = YES;
        break;
      }
    }
    if (!freshSiteInOldList) {
      // Reset impressions since freshness.
      GetApplicationContext()->GetLocalState()->SetInteger(
          prefs::kIosMagicStackSegmentationMVTImpressionsSinceFreshness, 0);
      base::RecordAction(
          base::UserMetricsAction("IOSMostVisitedTopSitesChanged"));
      return;
    }
  }
}

// Converts a ntp_tiles::NTPTile `tile` to a ContentSuggestionsMostVisitedItem
// with a `sectionInfo`.
- (ContentSuggestionsMostVisitedItem*)convertNTPTile:
    (const ntp_tiles::NTPTile&)tile {
  ContentSuggestionsMostVisitedItem* suggestion =
      [[ContentSuggestionsMostVisitedItem alloc] init];

  suggestion.title = base::SysUTF16ToNSString(tile.title);
  suggestion.URL = tile.url;
  suggestion.source = tile.source;
  suggestion.titleSource = tile.title_source;

  return suggestion;
}

@end