chromium/ios/chrome/browser/ui/content_suggestions/tab_resumption/tab_resumption_view.mm

// Copyright 2023 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/tab_resumption/tab_resumption_view.h"

#import "base/check.h"
#import "base/i18n/rtl.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "base/time/time.h"
#import "components/url_formatter/elide_url.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/content_suggestions/tab_resumption/tab_resumption_commands.h"
#import "ios/chrome/browser/ui/content_suggestions/tab_resumption/tab_resumption_constants.h"
#import "ios/chrome/browser/ui/content_suggestions/tab_resumption/tab_resumption_item.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"
#import "ui/base/l10n/time_format.h"
#import "url/gurl.h"

namespace {

const CGFloat kImageContainerCornerRadius = 12.0;

// Image container with Salient image constants
const CGFloat kImageSalientContainerSize = 72.0;

// Image container without Salient image constants
const CGFloat kImageEmptyContainerSize = 56.0;

// Center Favicon constants.
const CGFloat kCenterFaviconSize = 24.0;
const CGFloat kCenterSymbolSize = 20.0;
const CGFloat kCenterFaviconCornerRadius = 5.0;
const CGFloat kCenterFaviconBackgroundSize = 30.0;
const CGFloat kCenterFaviconBackgroundCornerRadius = 7.0;

// Corner Favicon constants.
const CGFloat kCornerFaviconSize = 16.0;
const CGFloat kCornerFaviconCornerRadius = 4.0;
const CGFloat kCornerFaviconBackgroundSize = 24.0;
const CGFloat kCornerFaviconBackgroundCornerRadius = 4.0;
const CGFloat kCornerFaviconBackgroundBottomTrailingCornerRadius = 8.0;
const CGFloat kCornerFaviconSpace = 6.0;

// Stacks constants.
const CGFloat kContainerStackSpacing = 14.0;
const CGFloat kNewLabelStackSpacing = 6.0;
const CGFloat kLabelStackSpacing = 5.0;

// Title constants.
const CGFloat kTitleLineSpacing = 18.0;

// The inset around the reason label.
const CGFloat kReasonInset = 4;

// Adds the fallback image that should be used if there is no salient nor
// favicon image.
void SetFallbackImageToImageView(UIImageView* image_view,
                                 UIView* background_view,
                                 CGFloat favicon_size) {
  if (IsTabResumption1_5Enabled()) {
    image_view.image =
        DefaultSymbolWithPointSize(kGlobeAmericasSymbol, kCenterSymbolSize);
    image_view.backgroundColor = [UIColor colorNamed:kBlue500Color];
    background_view.backgroundColor = [UIColor colorNamed:kBlue500Color];
    image_view.tintColor = UIColor.whiteColor;
  } else {
    image_view.image =
        CustomSymbolWithPointSize(kRecentTabsSymbol, kCenterSymbolSize);
    image_view.backgroundColor = [UIColor colorNamed:kGreen500Color];
    background_view.backgroundColor = [UIColor colorNamed:kGreen500Color];
    image_view.tintColor = UIColor.whiteColor;
  }
}

}  // namespace

@implementation TabResumptionView {
  // Item used to configure the view.
  TabResumptionItem* _item;
  // The view container.
  UIStackView* _containerStackView;

  // The reason views. Keep track to update corner radius.
  UILabel* _reasonLabel;
  UIView* _reasonLabelContainer;
  NSLayoutConstraint* _leadingLabelConstraint;
  NSLayoutConstraint* _trailingLabelConstraint;
}

- (instancetype)initWithItem:(TabResumptionItem*)item {
  self = [super init];
  if (self) {
    _item = item;
  }
  return self;
}

#pragma mark - UIView

- (void)willMoveToSuperview:(UIView*)newSuperview {
  [super willMoveToSuperview:newSuperview];

  if (!_containerStackView) {
    [self createSubviews];
    [self addTapGestureRecognizer];
  }
}

#if !defined(__IPHONE_17_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_17_0
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
  [super traitCollectionDidChange:previousTraitCollection];

  if (@available(iOS 17, *)) {
    return;
  }
  if (!_reasonLabelContainer) {
    return;
  }

  if (self.traitCollection.preferredContentSizeCategory !=
      previousTraitCollection.preferredContentSizeCategory) {
    [self updateCornerRadius];
  }
}
#endif

#pragma mark - Private methods

// Creates all the subviews.
- (void)createSubviews {
  self.accessibilityIdentifier = kTabResumptionViewIdentifier;
  self.isAccessibilityElement = YES;
  self.accessibilityTraits = UIAccessibilityTraitButton;

  _containerStackView = [self configuredContainerStackView];

  UIView* imageContainerView = [self configuredImageContainer];
  [_containerStackView addArrangedSubview:imageContainerView];

  UIStackView* labelStackView = [self configuredLabelStackView];
  [_containerStackView addArrangedSubview:labelStackView];

  NSMutableArray* accessibilityLabel = [NSMutableArray array];
  UILabel* sessionLabel;
  if (_item.itemType == TabResumptionItemType::kLastSyncedTab &&
      !IsTabResumption1_5Enabled()) {
    sessionLabel = [self configuredSessionLabel];
    [labelStackView addArrangedSubview:sessionLabel];
    [accessibilityLabel addObject:sessionLabel.text];
  }
  UILabel* tabTitleLabel = [self configuredTabTitleLabel];
  [labelStackView addArrangedSubview:tabTitleLabel];
  [accessibilityLabel addObject:tabTitleLabel.text];
  UILabel* hostnameAndSyncTimeLabel = [self configuredHostNameAndSyncTimeLabel];
  [labelStackView addArrangedSubview:hostnameAndSyncTimeLabel];
  [accessibilityLabel addObject:hostnameAndSyncTimeLabel.text];

  if (IsTabResumption2BubbleEnabled() && _item.reason) {
    // If there is a reason, limit the title to 1 line and add the reason.
    tabTitleLabel.numberOfLines = 1;
    UIView* reasonLabel = [self configuredReasonLabel];
    [labelStackView addArrangedSubview:reasonLabel];
    [accessibilityLabel addObject:_reasonLabel.text];
    if (@available(iOS 17, *)) {
      [self
          registerForTraitChanges:@[ UITraitPreferredContentSizeCategory.self ]
                       withAction:@selector(updateCornerRadius)];
    }
  }

  self.accessibilityLabel = [accessibilityLabel componentsJoinedByString:@","];

  [self addSubview:_containerStackView];
  AddSameConstraints(_containerStackView, self);
}

// Adds a tap gesture recognizer to the view.
- (void)addTapGestureRecognizer {
  UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc]
      initWithTarget:self
              action:@selector(tabResumptionItemTapped:)];
  [self addGestureRecognizer:tapRecognizer];
}

// Configures and returns the UIStackView that contains the faviconContainerView
// and the labelsStackView.
- (UIStackView*)configuredContainerStackView {
  UIStackView* containerStack = [[UIStackView alloc] init];
  containerStack.axis = UILayoutConstraintAxisHorizontal;
  containerStack.spacing = kContainerStackSpacing;
  containerStack.distribution = UIStackViewDistributionFill;
  containerStack.alignment = UIStackViewAlignmentCenter;
  containerStack.translatesAutoresizingMaskIntoConstraints = NO;
  return containerStack;
}

// Configures and returns the UIStackView that contains the different labels.
- (UIStackView*)configuredLabelStackView {
  UIStackView* labelStackView = [[UIStackView alloc] init];
  labelStackView.axis = UILayoutConstraintAxisVertical;
  labelStackView.alignment = UIStackViewAlignmentLeading;
  if (IsTabResumption1_5Enabled()) {
    labelStackView.spacing = kNewLabelStackSpacing;
  } else {
    labelStackView.spacing = kLabelStackSpacing;
  }
  labelStackView.translatesAutoresizingMaskIntoConstraints = NO;
  return labelStackView;
}

// Configures and returns the leading UIView that may contain the favicon image.
- (UIView*)configuredFaviconViewWithSalientImage:(BOOL)hasSalientImage {
  UIView* faviconBackgroundView = [[UIView alloc] init];
  faviconBackgroundView.translatesAutoresizingMaskIntoConstraints = NO;
  faviconBackgroundView.backgroundColor = UIColor.whiteColor;
  UIImageView* faviconImageView = [[UIImageView alloc] init];

  CGFloat faviconSize;
  CGFloat faviconCornerRadius;
  CGFloat faviconBackgoundSize;
  CGFloat faviconBackgroundCornerRadius;
  if (hasSalientImage) {
    faviconSize = kCornerFaviconSize;
    faviconCornerRadius = kCornerFaviconCornerRadius;
    faviconBackgoundSize = kCornerFaviconBackgroundSize;
    faviconBackgroundCornerRadius = kCornerFaviconBackgroundCornerRadius;
  } else {
    faviconSize = kCenterFaviconSize;
    faviconCornerRadius = kCenterFaviconCornerRadius;
    faviconBackgoundSize = kCenterFaviconBackgroundSize;
    faviconBackgroundCornerRadius = kCenterFaviconBackgroundCornerRadius;
  }

  if (_item.faviconImage) {
    faviconImageView.image = _item.faviconImage;
  } else {
    SetFallbackImageToImageView(faviconImageView, faviconBackgroundView,
                                faviconSize);
  }

  faviconImageView.translatesAutoresizingMaskIntoConstraints = NO;
  faviconImageView.contentMode = UIViewContentModeScaleAspectFit;
  faviconImageView.clipsToBounds = YES;
  [faviconBackgroundView addSubview:faviconImageView];

  faviconBackgroundView.layer.cornerRadius = faviconBackgroundCornerRadius;
  faviconImageView.layer.cornerRadius = faviconCornerRadius;
  [NSLayoutConstraint activateConstraints:@[
    [faviconBackgroundView.widthAnchor
        constraintEqualToConstant:faviconBackgoundSize],
    [faviconBackgroundView.heightAnchor
        constraintEqualToConstant:faviconBackgoundSize],
    [faviconImageView.widthAnchor constraintEqualToConstant:faviconSize],
    [faviconImageView.heightAnchor constraintEqualToConstant:faviconSize],
  ]];
  AddSameCenterConstraints(faviconBackgroundView, faviconImageView);

  if (hasSalientImage) {
    UIRectCorner bottomTrail = UIRectCornerBottomRight;
    if (base::i18n::IsRTL()) {
      bottomTrail = UIRectCornerBottomLeft;
    }

    CGSize cornerRadiusSize =
        CGSizeMake(kCornerFaviconBackgroundBottomTrailingCornerRadius,
                   kCornerFaviconBackgroundBottomTrailingCornerRadius);
    UIBezierPath* bezierPath = [UIBezierPath
        bezierPathWithRoundedRect:CGRectMake(0, 0, faviconBackgoundSize,
                                             faviconBackgoundSize)
                byRoundingCorners:bottomTrail
                      cornerRadii:cornerRadiusSize];
    CAShapeLayer* mask = [CAShapeLayer layer];
    mask.path = bezierPath.CGPath;
    faviconBackgroundView.layer.mask = mask;
  }
  return faviconBackgroundView;
}

// Configures and returns the leading UIView that may contain the Salient image.
- (UIView*)configuredSalientImageViewWithSize:(CGFloat)containerSize {
  UIImageView* salientView = [[UIImageView alloc] init];

  // Compute the size of the image.
  CGFloat width = _item.contentImage.size.width;
  CGFloat height = _item.contentImage.size.height;
  if (width > height) {
    width = (width * containerSize) / height;
    height = containerSize;
  } else {
    height = (height * containerSize) / width;
    width = containerSize;
  }

  // Resize the salient image.
  UIGraphicsBeginImageContextWithOptions(CGSize(width, height), NO, 0.0);
  [_item.contentImage drawInRect:CGRectMake(0, 0, width, height)];
  UIImage* scaledImage = UIGraphicsGetImageFromCurrentImageContext();
  UIGraphicsEndImageContext();
  [salientView setImage:scaledImage];

  salientView.translatesAutoresizingMaskIntoConstraints = NO;

  salientView.contentMode = UIViewContentModeTop;

  // Add a gradient overlay.
  CAGradientLayer* gradientLayer = [CAGradientLayer layer];
  gradientLayer.frame = CGRectMake(0, 0, containerSize, containerSize);
  gradientLayer.startPoint = CGPointMake(0.0, 0.0);
  gradientLayer.endPoint = CGPointMake(0.0, 1.0);
  gradientLayer.colors = @[
    static_cast<id>([UIColor clearColor].CGColor),
    static_cast<id>([UIColor colorWithWhite:0 alpha:0.2].CGColor)
  ];
  [salientView.layer insertSublayer:gradientLayer atIndex:0];
  return salientView;
}

// Configures and returns the leading UIView that contains the image.
- (UIView*)configuredImageContainer {
  UIView* containerView = [[UIView alloc] init];
  containerView.translatesAutoresizingMaskIntoConstraints = NO;
  containerView.layer.cornerRadius = kImageContainerCornerRadius;
  containerView.clipsToBounds = YES;

  BOOL hasSalientImage = NO;
  CGFloat containerSize;
  if (_item.contentImage &&
      (IsTabResumption1_5SalientImageEnabled() ||
       IsTabResumption1_5ThumbnailsImageEnabled()) &&
      _item.contentImage.size.width && _item.contentImage.size.height) {
    hasSalientImage = YES;
    containerSize = kImageSalientContainerSize;
    UIView* salientView =
        [self configuredSalientImageViewWithSize:containerSize];
    [containerView addSubview:salientView];
    AddSameConstraints(salientView, containerView);
  } else {
    containerView.backgroundColor =
        [UIColor colorNamed:kTertiaryBackgroundColor];
    containerSize = kImageEmptyContainerSize;
  }

  [NSLayoutConstraint activateConstraints:@[
    [containerView.widthAnchor constraintEqualToConstant:containerSize],
    [containerView.heightAnchor constraintEqualToConstant:containerSize],
  ]];

  if (hasSalientImage && !_item.faviconImage) {
    return containerView;
  }

  UIView* faviconView =
      [self configuredFaviconViewWithSalientImage:hasSalientImage];
  [containerView addSubview:faviconView];
  if (!hasSalientImage) {
    AddSameCenterConstraints(faviconView, containerView);
  } else {
    [NSLayoutConstraint activateConstraints:@[
      [faviconView.trailingAnchor
          constraintEqualToAnchor:containerView.trailingAnchor
                         constant:-kCornerFaviconSpace],
      [faviconView.bottomAnchor
          constraintEqualToAnchor:containerView.bottomAnchor
                         constant:-kCornerFaviconSpace],
    ]];
  }
  return containerView;
}

// Configures and returns the UILabel that contains the session name.
- (UILabel*)configuredSessionLabel {
  NSString* sessionString =
      l10n_util::GetNSStringF(IDS_IOS_TAB_RESUMPTION_TILE_HOST_LABEL,
                              base::SysNSStringToUTF16(_item.sessionName));

  UILabel* label = [[UILabel alloc] init];
  label.text = sessionString.uppercaseString;
  label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption2];
  label.numberOfLines = 1;
  label.lineBreakMode = NSLineBreakByTruncatingTail;
  label.adjustsFontForContentSizeCategory = YES;
  label.textColor = [UIColor colorNamed:kTextSecondaryColor];

  return label;
}

// Configures and returns the UILabel that contains the session name.
- (UILabel*)configuredTabTitleLabel {
  UILabel* label = [[UILabel alloc] init];
  NSString* text = [_item.tabTitle length]
                       ? _item.tabTitle
                       : l10n_util::GetNSString(
                             IDS_IOS_TAB_RESUMPTION_TAB_TITLE_PLACEHOLDER);
  if (!IsTabResumption1_5Enabled()) {
    label.text = text;
    label.font =
        CreateDynamicFont(UIFontTextStyleFootnote, UIFontWeightSemibold);
    label.numberOfLines = 1;
    label.lineBreakMode = NSLineBreakByTruncatingTail;
    label.adjustsFontForContentSizeCategory = YES;
    label.textColor = [UIColor colorNamed:kTextPrimaryColor];
  } else {
    // This is the default "Dynamic type" trait collection.
    // It is necessary to get the font in this size as it will be scaled later
    // using UIFontMetrics.
    UITraitCollection* traitCollection =
        [UITraitCollection traitCollectionWithPreferredContentSizeCategory:
                               UIContentSizeCategoryLarge];
    UIFontDescriptor* descriptor = [UIFontDescriptor
        preferredFontDescriptorWithTextStyle:UIFontTextStyleFootnote
               compatibleWithTraitCollection:traitCollection];
    UIFont* font = [UIFont systemFontOfSize:descriptor.pointSize
                                     weight:UIFontWeightSemibold];

    NSMutableParagraphStyle* style = [[NSMutableParagraphStyle alloc] init];
    style.lineHeightMultiple = kTitleLineSpacing / font.lineHeight;
    style.lineBreakMode = NSLineBreakByTruncatingTail;
    UIFontMetrics* footnoteMetrics =
        [UIFontMetrics metricsForTextStyle:UIFontTextStyleFootnote];
    NSAttributedString* attrString = [[NSAttributedString alloc]
        initWithString:text
            attributes:@{
              NSFontAttributeName : [footnoteMetrics scaledFontForFont:font],
              NSForegroundColorAttributeName :
                  [UIColor colorNamed:kTextPrimaryColor],
              NSParagraphStyleAttributeName : style
            }];
    label.attributedText = attrString;
    label.numberOfLines = IsTabResumption1_5Enabled() ? 2 : 1;
    label.adjustsFontForContentSizeCategory = YES;
  }
  return label;
}

// Configures and returns the UILabel that contains the session name.
- (UILabel*)configuredHostNameAndSyncTimeLabel {
  NSString* hostnameAndSyncTimeString;
  UILabel* label = [[UILabel alloc] init];
  if (IsTabResumption1_5Enabled()) {
    if (_item.itemType == kLastSyncedTab && _item.sessionName) {
      hostnameAndSyncTimeString = [NSString
          stringWithFormat:@"%@ • %@", [self hostnameFromGURL:_item.tabURL],
                           _item.sessionName];
    } else {
      hostnameAndSyncTimeString = [NSString
          stringWithFormat:@"%@", [self hostnameFromGURL:_item.tabURL]];
    }
    label.lineBreakMode = NSLineBreakByTruncatingTail;
    label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption2];
  } else {
    hostnameAndSyncTimeString = [NSString
        stringWithFormat:@"%@ • %@", [self hostnameFromGURL:_item.tabURL],
                         [self lastSyncTimeStringFromTime:_item.syncedTime]];
    label.lineBreakMode = NSLineBreakByTruncatingMiddle;
    label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
  }

  label.text = hostnameAndSyncTimeString;

  label.numberOfLines = 1;
  label.adjustsFontForContentSizeCategory = YES;
  label.textColor = [UIColor colorNamed:kTextSecondaryColor];

  return label;
}

// Configures and returns the UILabel that contains the reason. Note: The label
// is contained in a background UIView, hence the UIView return type.
- (UIView*)configuredReasonLabel {
  _reasonLabel = [[UILabel alloc] init];
  _reasonLabel.translatesAutoresizingMaskIntoConstraints = NO;
  _reasonLabel.adjustsFontForContentSizeCategory = YES;
  _reasonLabel.textColor = [UIColor colorNamed:kTextSecondaryColor];

  [_reasonLabel setText:_item.reason];
  UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption2];
  _reasonLabel.font = font;

  _reasonLabelContainer = [[UIView alloc] init];
  _reasonLabelContainer.translatesAutoresizingMaskIntoConstraints = NO;
  [_reasonLabelContainer addSubview:_reasonLabel];
  _reasonLabelContainer.backgroundColor =
      [UIColor colorNamed:kSecondaryBackgroundColor];

  _leadingLabelConstraint = [_reasonLabel.leadingAnchor
      constraintEqualToAnchor:_reasonLabelContainer.leadingAnchor];
  _trailingLabelConstraint = [_reasonLabel.trailingAnchor
      constraintEqualToAnchor:_reasonLabelContainer.trailingAnchor];
  [NSLayoutConstraint activateConstraints:@[
    _leadingLabelConstraint,
    _trailingLabelConstraint,
    [_reasonLabel.topAnchor
        constraintEqualToAnchor:_reasonLabelContainer.topAnchor
                       constant:kReasonInset],
    [_reasonLabel.bottomAnchor
        constraintEqualToAnchor:_reasonLabelContainer.bottomAnchor
                       constant:-kReasonInset],
  ]];
  [self updateCornerRadius];

  return _reasonLabelContainer;
}

// Updates the corner radius of the reason label container.
- (void)updateCornerRadius {
  UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption2];
  CGFloat radius = font.lineHeight / 2 + kReasonInset;
  _leadingLabelConstraint.constant = font.lineHeight / 2;
  _trailingLabelConstraint.constant = -font.lineHeight / 2;
  _reasonLabelContainer.layer.cornerRadius = radius;
}

// Returns the tab hostname from the given `URL`.
- (NSString*)hostnameFromGURL:(GURL)URL {
  return base::SysUTF16ToNSString(
      url_formatter::
          FormatUrlForDisplayOmitSchemePathTrivialSubdomainsAndMobilePrefix(
              URL));
}

// Returns the last sync string from the given `time`.
- (NSString*)lastSyncTimeStringFromTime:(base::Time)time {
  base::TimeDelta lastUsedDelta = base::Time::Now() - time;
  base::TimeDelta oneMinuteDelta = base::Minutes(1);

  // If the tab was synchronized within the last minute, returns 'just now'.
  if (lastUsedDelta < oneMinuteDelta) {
    return l10n_util::GetNSString(IDS_IOS_TAB_RESUMPTION_JUST_NOW);
  }

  // This will return something similar to "2 mins/hours ago".
  return base::SysUTF16ToNSString(
      ui::TimeFormat::Simple(ui::TimeFormat::FORMAT_ELAPSED,
                             ui::TimeFormat::LENGTH_SHORT, lastUsedDelta));
}

// Called when the view has been tapped.
- (void)tabResumptionItemTapped:(UIGestureRecognizer*)sender {
  [self.commandHandler openTabResumptionItem:_item];
}

@end