diff --git a/Components/Sources/Components/Assets.xcassets/ImageRecommendations/Contents.json b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Components/Sources/Components/Assets.xcassets/ImageRecommendations/bot.imageset/Contents.json b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/bot.imageset/Contents.json new file mode 100644 index 00000000000..1c50464669d --- /dev/null +++ b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/bot.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bot.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Components/Sources/Components/Assets.xcassets/ImageRecommendations/bot.imageset/bot.pdf b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/bot.imageset/bot.pdf new file mode 100644 index 00000000000..b275909dc55 Binary files /dev/null and b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/bot.imageset/bot.pdf differ diff --git a/Components/Sources/Components/Assets.xcassets/ImageRecommendations/external-link.imageset/Contents.json b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/external-link.imageset/Contents.json new file mode 100644 index 00000000000..0b21453b83f --- /dev/null +++ b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/external-link.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "mini-external.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Components/Sources/Components/Assets.xcassets/ImageRecommendations/external-link.imageset/mini-external.pdf b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/external-link.imageset/mini-external.pdf new file mode 100644 index 00000000000..c4443a40863 Binary files /dev/null and b/Components/Sources/Components/Assets.xcassets/ImageRecommendations/external-link.imageset/mini-external.pdf differ diff --git a/Components/Sources/Components/Components/Shared/WKArticleSummaryView.swift b/Components/Sources/Components/Components/Shared/WKArticleSummaryView.swift index 3b6f76c1d91..d0ef1f0a494 100644 --- a/Components/Sources/Components/Components/Shared/WKArticleSummaryView.swift +++ b/Components/Sources/Components/Components/Shared/WKArticleSummaryView.swift @@ -19,6 +19,8 @@ struct WKArticleSummaryView: View { } var body: some View { + + VStack(alignment: .leading, spacing: 8) { Spacer() .frame(height: 12) @@ -43,5 +45,8 @@ struct WKArticleSummaryView: View { .frame(height: 2) WKHtmlText(html: articleSummary.extractHtml, styles: summaryStyles) } + } + + } diff --git a/Components/Sources/Components/Components/Shared/WKHtmlText.swift b/Components/Sources/Components/Components/Shared/WKHtmlText.swift index 09e339d21dc..3ca5337feb6 100644 --- a/Components/Sources/Components/Components/Shared/WKHtmlText.swift +++ b/Components/Sources/Components/Components/Shared/WKHtmlText.swift @@ -19,5 +19,6 @@ struct WKHtmlText: View { Text(attributedString) .lineLimit(nil) .lineSpacing(styles.lineSpacing) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationBottomSheetView.swift b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationBottomSheetView.swift new file mode 100644 index 00000000000..5f62746f7af --- /dev/null +++ b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationBottomSheetView.swift @@ -0,0 +1,383 @@ +import UIKit + +protocol WKImageRecommendationsToolbarViewDelegate: AnyObject { + func didTapYesButton() + func didTapNoButton() + func didTapSkipButton() + func goToImageCommonsPage() +} + +public class WKImageRecommendationBottomSheetView: WKComponentView { + + // MARK: Properties + + private var viewModel: WKImageRecommendationBottomSheetViewModel + internal weak var delegate: WKImageRecommendationsToolbarViewDelegate? + + private lazy var container: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .fill + stackView.axis = .vertical + return stackView + }() + + private lazy var headerStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.distribution = .fill + stackView.alignment = .top + stackView.axis = .horizontal + return stackView + }() + + private lazy var imageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.backgroundColor = .gray + return imageView + }() + + private lazy var textView: UITextView = { + let textView = UITextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.setContentCompressionResistancePriority(.required, for: .vertical) + textView.setContentHuggingPriority(.defaultLow, for: .vertical) + textView.adjustsFontForContentSizeCategory = true + textView.textAlignment = effectiveUserInterfaceLayoutDirection == .rightToLeft ? .right : .left + textView.isScrollEnabled = false + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = UIEdgeInsets(top: 10, left: 0, bottom: -10, right: -10) + textView.textContainer.lineFragmentPadding = 0 + textView.isUserInteractionEnabled = false + textView.backgroundColor = .clear + textView.font = WKFont.for(.callout) + return textView + }() + + private lazy var iconImageView: UIImageView = { + let icon = WKIcon.bot + let imageView = UIImageView(image: icon) +// imageView.backgroundColor = .yellow + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() +// label.backgroundColor = .red + label.font = WKFont.for(.boldTitle3) + return label + }() + + private let buttonFont: UIFont = WKFont.for(.boldCallout) + + private lazy var imageLinkButton: WKButton = { + let button = WKButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.titleLabel?.font = buttonFont + button.titleLabel?.numberOfLines = 0 + button.titleLabel?.lineBreakMode = .byWordWrapping + button.configuration?.contentInsets = .zero + button.configuration?.titlePadding = .zero + button.contentHorizontalAlignment = effectiveUserInterfaceLayoutDirection == .rightToLeft ? .right : .left + button.sizeToFit() + button.addTarget(self, action: #selector(goToImageCommonsPage), for: .touchUpInside) + return button + }() + + private lazy var toolbar: UIToolbar = { + let toolbar = UIToolbar() + toolbar.translatesAutoresizingMaskIntoConstraints = false + return toolbar + }() + + lazy var yesToolbarButton: UIBarButtonItem = { + let customView = UIView() + + let imageView = UIImageView(image: WKSFSymbolIcon.for(symbol: .checkmark)) + imageView.contentMode = .scaleAspectFit + imageView.tintColor = theme.link + imageView.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = viewModel.yesButtonTitle + label.textColor = theme.link + label.font = WKFont.for(.boldCallout) + label.translatesAutoresizingMaskIntoConstraints = false + + let button = UIButton(type: .custom) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(didPressYesButton), for: .touchUpInside) + customView.addSubview(imageView) + customView.addSubview(label) + customView.addSubview(button) + + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: customView.leadingAnchor), + imageView.centerYAnchor.constraint(equalTo: customView.centerYAnchor), + imageView.widthAnchor.constraint(equalToConstant: 20), + imageView.heightAnchor.constraint(equalToConstant: 20), + label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: customView.trailingAnchor), + label.centerYAnchor.constraint(equalTo: customView.centerYAnchor), + + button.topAnchor.constraint(equalTo: customView.topAnchor), + button.bottomAnchor.constraint(equalTo: customView.bottomAnchor), + button.leadingAnchor.constraint(equalTo: customView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: customView.trailingAnchor) + ]) + + customView.layoutIfNeeded() + let customViewWidth = label.frame.origin.x + label.frame.width + customView.frame = CGRect(x: 0, y: 0, width: customViewWidth, height: 40) + let barButtonItem = UIBarButtonItem(customView: customView) + + return barButtonItem + }() + + lazy var noToolbarButton: UIBarButtonItem = { + let customView = UIView() + + let imageView = UIImageView(image: WKSFSymbolIcon.for(symbol: .xMark)) + imageView.contentMode = .scaleAspectFit + imageView.tintColor = theme.link + imageView.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = viewModel.noButtonTitle + label.font = WKFont.for(.boldCallout) + label.textColor = theme.link + label.translatesAutoresizingMaskIntoConstraints = false + + let button = UIButton(type: .custom) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(didPressNoButton), for: .touchUpInside) + customView.addSubview(imageView) + customView.addSubview(label) + customView.addSubview(button) + + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: customView.leadingAnchor), + imageView.centerYAnchor.constraint(equalTo: customView.centerYAnchor), + imageView.widthAnchor.constraint(equalToConstant: 20), + imageView.heightAnchor.constraint(equalToConstant: 20), + + label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: customView.trailingAnchor), + label.centerYAnchor.constraint(equalTo: customView.centerYAnchor), + + button.topAnchor.constraint(equalTo: customView.topAnchor), + button.bottomAnchor.constraint(equalTo: customView.bottomAnchor), + button.leadingAnchor.constraint(equalTo: customView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: customView.trailingAnchor) + ]) + + customView.layoutIfNeeded() + let customViewWidth = label.frame.origin.x + label.frame.width + customView.frame = CGRect(x: 0, y: 0, width: customViewWidth, height: 40) + let barButtonItem = UIBarButtonItem(customView: customView) + + return barButtonItem + }() + + lazy var notSureToolbarButton: UIBarButtonItem = { + let barButton = UIBarButtonItem(title: viewModel.notSureButtonTitle, style: .plain, target: self, action: #selector(didPressSkipButton)) + barButton.tintColor = theme.link + + let attributes: [NSAttributedString.Key: Any] = [ + .font: WKFont.for(.boldCallout), + .foregroundColor: theme.link + ] + + barButton.setTitleTextAttributes(attributes, for: .normal) + return barButton + }() + + private var regularSizeClass: Bool { + return traitCollection.horizontalSizeClass == .regular && + traitCollection.horizontalSizeClass == .regular ? true : false + } + + private var padding: CGFloat { + return regularSizeClass ? 32 : 16 + } + + private var imageViewWidth: CGFloat { + return regularSizeClass ? self.frame.width/2-padding : 150 + } + + private var imageViewHeight: CGFloat { + return regularSizeClass ? UIScreen.main.bounds.height/4 : 150 + } + + private var cutoutWidth: CGFloat { + return imageViewWidth+padding + } + + private var linkButtonWidth: CGFloat { + return (self.frame.width-cutoutWidth)-padding*2 + } + + private var buttonHeight: CGFloat { + let title = viewModel.imageTitle + // The "1 1" here is a hack to help calculating the size of the NSAttributedString with attachment, since it can't be used to calculate the text size here due to not being convertible to NSString + let imageTitleTextSize = (title + "1 1" as NSString).boundingRect( + with: CGSize(width: linkButtonWidth, height: .greatestFiniteMagnitude), + options: .usesLineFragmentOrigin, + attributes: [.font: buttonFont], + context: nil).size + return imageTitleTextSize.height + } + + + // MARK: Lifecycle + + public init(frame: CGRect, viewModel: WKImageRecommendationBottomSheetViewModel) { + self.viewModel = viewModel + super.init(frame: frame) + setup() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Private Methods + + private func setup() { + configure() + container.addSubview(imageLinkButton) + container.addSubview(textView) + container.addSubview(imageView) + + headerStackView.addArrangedSubview(iconImageView) + headerStackView.addArrangedSubview(titleLabel) + headerStackView.spacing = 10 + + stackView.addArrangedSubview(headerStackView) + stackView.addArrangedSubview(container) + stackView.spacing = padding + addSubview(stackView) + addSubview(toolbar) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: padding), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding), + imageView.topAnchor.constraint(equalTo: container.topAnchor), + imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + imageLinkButton.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: padding), + imageLinkButton.heightAnchor.constraint(greaterThanOrEqualToConstant: buttonHeight), + imageLinkButton.widthAnchor.constraint(equalToConstant: linkButtonWidth), + textView.topAnchor.constraint(equalTo: imageLinkButton.bottomAnchor), + textView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + iconImageView.heightAnchor.constraint(equalToConstant: 20), + iconImageView.widthAnchor.constraint(equalToConstant: 20), + toolbar.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), + toolbar.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + toolbar.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + toolbar.heightAnchor.constraint(equalToConstant: 44) + ]) + + let linkButtonTopConstarint = imageLinkButton.topAnchor.constraint(equalTo: imageView.topAnchor) + linkButtonTopConstarint.priority = .required + let imageWidthConstraint = imageView.widthAnchor.constraint(equalToConstant: imageViewWidth) + imageWidthConstraint.priority = .required + let imageHeightConstraint = imageView.heightAnchor.constraint(equalToConstant: imageViewHeight) + imageHeightConstraint.priority = .required + + NSLayoutConstraint.activate([ + linkButtonTopConstarint, + imageWidthConstraint, + imageHeightConstraint + ]) + + setupTextViewExclusionPath() + updateColors() + setupToolbar() + } + + private func setupTextViewExclusionPath() { + let height = regularSizeClass ? imageViewHeight - buttonHeight : (imageViewHeight - padding) - buttonHeight + let rectangleWidth: CGFloat = cutoutWidth + let rectangleHeight: CGFloat = height + + let layoutDirection = textView.effectiveUserInterfaceLayoutDirection + let isRTL = layoutDirection == .rightToLeft + + let rectangleOriginX: CGFloat + if isRTL { + let width = self.frame.width + rectangleOriginX = width - rectangleWidth - textView.textContainerInset.right - padding * 2.5 + } else { + rectangleOriginX = textView.textContainerInset.left + } + + let rectangleOriginY: CGFloat = textView.textContainerInset.top + + let rectangleFrame = CGRect(x: rectangleOriginX, y: rectangleOriginY, width: rectangleWidth, height: rectangleHeight) + let rectanglePath = UIBezierPath(rect: rectangleFrame) + + textView.textContainer.exclusionPaths = [rectanglePath] + } + + private func updateColors() { + backgroundColor = theme.paperBackground + textView.textColor = theme.secondaryText + titleLabel.textColor = theme.text + imageLinkButton.setTitleColor(theme.link, for: .normal) + iconImageView.tintColor = theme.link + toolbar.barTintColor = theme.midBackground + } + + private func configure() { + imageView.image = viewModel.imageThumbnail + textView.text = viewModel.imageDescription + titleLabel.text = viewModel.headerTitle + imageLinkButton.setAttributedTitle(getImageLinkButtonTitle(), for: .normal) + } + + private func getImageLinkButtonTitle() -> NSMutableAttributedString { + let attributedString = NSMutableAttributedString() + if let imageAttachment = WKIcon.externalLink { + let attachment = NSTextAttachment(image: imageAttachment) + attributedString.append(NSAttributedString(string: viewModel.imageTitle)) + attributedString.append(NSAttributedString(string: " ")) + attributedString.append(NSAttributedString(attachment: attachment)) + } + return attributedString + } + + private func setupToolbar() { + let spacer = UIBarButtonItem(systemItem: .flexibleSpace) + toolbar.setItems([yesToolbarButton, spacer, noToolbarButton, spacer, notSureToolbarButton], animated: true) + } + + @objc private func goToImageCommonsPage() { + delegate?.goToImageCommonsPage() + } + + @objc private func didPressYesButton() { + delegate?.didTapYesButton() + } + + @objc private func didPressNoButton() { + delegate?.didTapNoButton() + } + + @objc private func didPressSkipButton() { + delegate?.didTapSkipButton() + } + +} diff --git a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationBottomSheetViewModel.swift b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationBottomSheetViewModel.swift new file mode 100644 index 00000000000..4d163512f07 --- /dev/null +++ b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationBottomSheetViewModel.swift @@ -0,0 +1,42 @@ +import UIKit +import WKData + +public class WKImageRecommendationBottomSheetViewModel { + + let pageId: Int + let headerTitle: String + let imageThumbnail: UIImage? + let imageLink: String + let thumbLink: String + let imageTitle: String + var imageDescription: String? + let yesButtonTitle: String + let noButtonTitle: String + let notSureButtonTitle: String + + public init(pageId: Int, headerTitle: String, imageThumbnail: UIImage?, imageLink: String, thumbLink: String, imageTitle: String, imageDescription: String?, yesButtonTitle: String, noButtonTitle: String, notSureButtonTitle: String) { + self.pageId = pageId + self.headerTitle = headerTitle + self.imageThumbnail = imageThumbnail + self.imageLink = imageLink + self.thumbLink = thumbLink + self.imageTitle = imageTitle + self.imageDescription = imageDescription + self.yesButtonTitle = yesButtonTitle + self.noButtonTitle = noButtonTitle + self.notSureButtonTitle = notSureButtonTitle + + update() + } + + private func update() { + if let description = imageDescription { + imageDescription = getCleanDescription(from: description) + } + } + + private func getCleanDescription(from input: String) -> String? { + return try? HtmlUtils.stringFromHTML(input) + } + +} diff --git a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsBottomSheetViewController.swift b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsBottomSheetViewController.swift new file mode 100644 index 00000000000..5268d39b607 --- /dev/null +++ b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsBottomSheetViewController.swift @@ -0,0 +1,73 @@ +import UIKit +import WKData + +final public class WKImageRecommendationsBottomSheetViewController: WKCanvasViewController { + + // MARK: Properties + + public var viewModel: WKImageRecommendationsViewModel + + // MARK: Lifecycle + + public init(viewModel: WKImageRecommendationsViewModel) { + self.viewModel = viewModel + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if let bottomViewModel = populateImageSheetRecommendationViewModel(for: viewModel.currentRecommendation?.imageData) { + let bottomSheetView = WKImageRecommendationBottomSheetView(frame: UIScreen.main.bounds, viewModel: bottomViewModel) + bottomSheetView.delegate = self + addComponent(bottomSheetView, pinToEdges: true) + } + } + + // MARK: Methods + + private func populateImageSheetRecommendationViewModel(for image: WKImageRecommendationData?) -> WKImageRecommendationBottomSheetViewModel? { + + if let image { + let viewModel = WKImageRecommendationBottomSheetViewModel( + pageId: image.pageId, + headerTitle: viewModel.localizedStrings.bottomSheetTitle, + imageThumbnail: UIImage(), + imageLink: image.fullUrl, + thumbLink: image.thumbUrl, + imageTitle: image.filename, + imageDescription: image.description, + yesButtonTitle: viewModel.localizedStrings.yesButtonTitle, + noButtonTitle: viewModel.localizedStrings.noButtonTitle, + notSureButtonTitle: viewModel.localizedStrings.notSureButtonTitle + ) + return viewModel + } + return nil + } + +} +extension WKImageRecommendationsBottomSheetViewController: WKImageRecommendationsToolbarViewDelegate { + func goToImageCommonsPage() { + + } + + func didTapYesButton() { + + } + + func didTapNoButton() { + + } + + func didTapSkipButton() { + self.dismiss(animated: true) { + self.viewModel.next { + + } + } + } +} diff --git a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsView.swift b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsView.swift index 0b9610b2a69..b05b6b9b73b 100644 --- a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsView.swift +++ b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsView.swift @@ -2,45 +2,55 @@ import SwiftUI import Combine struct WKImageRecommendationsView: View { - + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @ObservedObject var appEnvironment = WKAppEnvironment.current @ObservedObject var viewModel: WKImageRecommendationsViewModel let viewArticleAction: (String) -> Void - + + var sizeClassPadding: CGFloat { + horizontalSizeClass == .regular ? 64 : 16 + } + var body: some View { Group { - if let articleSummary = viewModel.currentRecommendation?.articleSummary, - !viewModel.debouncedLoading { - VStack { - WKArticleSummaryView(articleSummary: articleSummary) - Spacer() - .frame(height: 19) - HStack { - Spacer() - let configuration = WKSmallButton.Configuration(style: .quiet, needsDisclosure: true) - WKSmallButton(configuration: configuration, title: "View article") { - if let articleTitle = viewModel.currentRecommendation?.title { - viewArticleAction(articleTitle) + ZStack { + Color(appEnvironment.theme.paperBackground) + if let articleSummary = viewModel.currentRecommendation?.articleSummary, + !viewModel.debouncedLoading { + GeometryReader { geometry in + ScrollView(.vertical, showsIndicators: true) { + VStack { + HStack { + WKArticleSummaryView(articleSummary: articleSummary) + } + Spacer() + .frame(height: 19) + HStack { + Spacer() + let configuration = WKSmallButton.Configuration(style: .quiet, needsDisclosure: true) + WKSmallButton(configuration: configuration, title: viewModel.localizedStrings.viewArticle) { + if let articleTitle = viewModel.currentRecommendation?.title { + viewArticleAction(articleTitle) + } + } + } } + .padding([.leading, .trailing, .bottom], sizeClassPadding) + Spacer() + .frame(idealHeight: geometry.size.height/3*2) } } - - Spacer() - Button(action: { - viewModel.next { - - } - }, label: { - Text("Next") - }) - } - .padding([.leading, .trailing, .bottom]) - } else { - if !viewModel.debouncedLoading { - Text("Empty") + } else { - ProgressView() + if !viewModel.debouncedLoading { + Text("Empty") + } else { + ProgressView() + } } } + .ignoresSafeArea() } .onAppear { viewModel.fetchImageRecommendationsIfNeeded { diff --git a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewController.swift b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewController.swift index 08767c937ee..63b34bd2269 100644 --- a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewController.swift +++ b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewController.swift @@ -1,6 +1,7 @@ -import Foundation import SwiftUI +import UIKit import WKData +import Combine public protocol WKImageRecommendationsDelegate: AnyObject { func imageRecommendationsUserDidTapViewArticle(project: WKProject, title: String) @@ -21,37 +22,100 @@ fileprivate final class WKImageRecommendationsHostingViewController: WKComponent } public final class WKImageRecommendationsViewController: WKCanvasViewController { - + // MARK: - Properties fileprivate let hostingViewController: WKImageRecommendationsHostingViewController private weak var delegate: WKImageRecommendationsDelegate? - private let viewModel: WKImageRecommendationsViewModel + @ObservedObject private var viewModel: WKImageRecommendationsViewModel + private var imageRecommendationBottomSheetController: WKImageRecommendationsBottomSheetViewController + private var cancellables = Set() + private var regularSizeClass: Bool { + return traitCollection.horizontalSizeClass == .regular && + traitCollection.horizontalSizeClass == .regular ? true : false + } + + // MARK: Lifecycle public init(viewModel: WKImageRecommendationsViewModel, delegate: WKImageRecommendationsDelegate) { self.hostingViewController = WKImageRecommendationsHostingViewController(viewModel: viewModel, delegate: delegate) self.delegate = delegate self.viewModel = viewModel + self.imageRecommendationBottomSheetController = WKImageRecommendationsBottomSheetViewController(viewModel: viewModel) super.init() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(false, animated: false) + } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(true, animated: false) - } - + public override func viewDidLoad() { super.viewDidLoad() title = viewModel.localizedStrings.title addComponent(hostingViewController, pinToEdges: true) } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + bindViewModel() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(true, animated: false) + if imageRecommendationBottomSheetController.sheetPresentationController != nil { + imageRecommendationBottomSheetController.isModalInPresentation = false + } + imageRecommendationBottomSheetController.dismiss(animated: true) + } + + // MARK: Private methods + + private func presentModalView() { + if regularSizeClass { + presentImageRecommendationPopover() + } else { + presentImageRecommendationBottomSheet() + } + } + + private func presentImageRecommendationBottomSheet() { + imageRecommendationBottomSheetController.isModalInPresentation = true + if let bottomSheet = imageRecommendationBottomSheetController.sheetPresentationController { + bottomSheet.detents = [.medium(), .large()] + bottomSheet.largestUndimmedDetentIdentifier = .medium + bottomSheet.prefersGrabberVisible = true + bottomSheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true + } + navigationController?.present(imageRecommendationBottomSheetController, animated: true) + } + + private func presentImageRecommendationPopover() { + imageRecommendationBottomSheetController.isModalInPresentation = true + if let popover = imageRecommendationBottomSheetController.popoverPresentationController { + let sheet = popover.adaptiveSheetPresentationController + sheet.detents = [.medium(), .large()] + sheet.largestUndimmedDetentIdentifier = .medium + sheet.prefersGrabberVisible = true + } + navigationController?.present(imageRecommendationBottomSheetController, animated: true) + } + + private func bindViewModel() { + viewModel.$loading + .receive(on: RunLoop.main) + .sink { [weak self] presentBottomSheet in + if !presentBottomSheet { + self?.presentModalView() + } + } + .store(in: &cancellables) + } } + diff --git a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewModel.swift b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewModel.swift index bf1a259444c..dd7f2a17982 100644 --- a/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewModel.swift +++ b/Components/Sources/Components/Components/Suggested Edits/Image Recommendations/WKImageRecommendationsViewModel.swift @@ -9,10 +9,18 @@ public final class WKImageRecommendationsViewModel: ObservableObject { public struct LocalizedStrings { let title: String let viewArticle: String - - public init(title: String, viewArticle: String) { + let bottomSheetTitle: String + let yesButtonTitle: String + let noButtonTitle: String + let notSureButtonTitle: String + + public init(title: String, viewArticle: String, bottomSheetTitle: String, yesButtonTitle: String, noButtonTitle: String, notSureButtonTitle: String) { self.title = title self.viewArticle = viewArticle + self.bottomSheetTitle = bottomSheetTitle + self.yesButtonTitle = yesButtonTitle + self.noButtonTitle = noButtonTitle + self.notSureButtonTitle = notSureButtonTitle } } @@ -21,11 +29,13 @@ public final class WKImageRecommendationsViewModel: ObservableObject { let pageId: Int let title: String @Published var articleSummary: WKArticleSummary? = nil - - fileprivate init(pageId: Int, title: String, articleSummary: WKArticleSummary? = nil) { + let imageData: WKImageRecommendationData + + fileprivate init(pageId: Int, title: String, articleSummary: WKArticleSummary? = nil, imageData: WKImageRecommendationData) { self.pageId = pageId self.title = title self.articleSummary = articleSummary + self.imageData = imageData } } @@ -34,9 +44,10 @@ public final class WKImageRecommendationsViewModel: ObservableObject { let project: WKProject let localizedStrings: LocalizedStrings - private(set) var recommendations: [ImageRecommendation] = [] + private(set) var imageRecommendations: [ImageRecommendation] = [] + private var recommendationData: [WKImageRecommendation.Page] = [] @Published var currentRecommendation: ImageRecommendation? - @Published private var loading: Bool = true + @Published var loading: Bool = true @Published var debouncedLoading: Bool = true private var subscriptions = Set() @@ -63,15 +74,15 @@ public final class WKImageRecommendationsViewModel: ObservableObject { func fetchImageRecommendationsIfNeeded(completion: @escaping () -> Void) { - guard recommendations.isEmpty else { + guard imageRecommendations.isEmpty else { completion() return } loading = true - - growthTasksDataController.getGrowthAPITask(task: .imageRecommendation) { [weak self] result in - + + growthTasksDataController.getImageRecommendationsCombined { [weak self] result in + guard let self else { completion() return @@ -80,9 +91,18 @@ public final class WKImageRecommendationsViewModel: ObservableObject { switch result { case .success(let pages): DispatchQueue.main.async { - self.recommendations = pages.map { ImageRecommendation(pageId: $0.pageid, title: $0.title) } - if let firstRecommendation = self.recommendations.first { - self.populateCurrentRecommendation(for: firstRecommendation, completion: { + self.recommendationData = pages + let imageDataArray = self.getFirstImageData(for: pages) + + for page in pages { + if let imageData = imageDataArray.first(where: { $0.pageId == page.pageid}) { + let combinedImageRecommendation = ImageRecommendation(pageId: page.pageid, title: page.title, imageData: imageData) + self.imageRecommendations.append(combinedImageRecommendation) + } + } + + if let firstRecommendation = self.imageRecommendations.first { + self.populateCurrentArticleSummary(for: firstRecommendation, completion: { DispatchQueue.main.async { self.loading = false completion() @@ -100,14 +120,14 @@ public final class WKImageRecommendationsViewModel: ObservableObject { } func next(completion: @escaping () -> Void) { - guard !recommendations.isEmpty else { + guard !imageRecommendations.isEmpty else { self.currentRecommendation = nil completion() return } - recommendations.removeFirst() - guard let nextRecommendation = recommendations.first else { + imageRecommendations.removeFirst() + guard let nextRecommendation = imageRecommendations.first else { self.currentRecommendation = nil completion() return @@ -115,13 +135,13 @@ public final class WKImageRecommendationsViewModel: ObservableObject { loading = true - populateCurrentRecommendation(for: nextRecommendation, completion: { [weak self] in + populateCurrentArticleSummary(for: nextRecommendation, completion: { [weak self] in completion() self?.loading = false }) } - - func populateCurrentRecommendation(for imageRecommendation: ImageRecommendation, completion: @escaping () -> Void) { + + func populateCurrentArticleSummary(for imageRecommendation: ImageRecommendation, completion: @escaping () -> Void) { articleSummaryDataController.fetchArticleSummary(project: project, title: imageRecommendation.title) { [weak self] result in @@ -148,4 +168,25 @@ public final class WKImageRecommendationsViewModel: ObservableObject { } } } + + fileprivate func getFirstImageData(for pages: [WKImageRecommendation.Page]) -> [WKImageRecommendationData] { + + var imageData: [WKImageRecommendationData] = [] + for page in pages { + if let firstPageSuggestion = page.growthimagesuggestiondata?.first, + let firstImage = firstPageSuggestion.images.first { + let metadata = firstImage.metadata + let imageRecommendation = WKImageRecommendationData( + pageId: page.pageid, + image: firstImage.image, + filename: firstImage.displayFilename, + thumbUrl: metadata.thumbUrl, + fullUrl: metadata.fullUrl, + description: metadata.description + ) + imageData.append(imageRecommendation) + } + } + return imageData + } } diff --git a/Components/Sources/Components/Style/WKIcon.swift b/Components/Sources/Components/Style/WKIcon.swift index 0731bf3ecd1..ecb5da9ec94 100644 --- a/Components/Sources/Components/Style/WKIcon.swift +++ b/Components/Sources/Components/Style/WKIcon.swift @@ -18,6 +18,8 @@ public enum WKIcon { static let replace = UIImage(named: "replace", in: .module, with: nil) static let thank = UIImage(named: "thank", in: .module, with: nil) static let userContributions = UIImage(named: "user-contributions", in: .module, with: nil) + static let externalLink = UIImage(named: "external-link", in: .module, with: nil) + static let bot = UIImage(named: "bot", in: .module, with: nil) // Project icons static let commons = UIImage(named: "project-icons/commons", in: .module, with: nil) @@ -67,6 +69,7 @@ public enum WKSFSymbolIcon { case redo case textFormatSize case textFormat + case xMark public static func `for`(symbol: WKSFSymbolIcon, font: WKFont = .subheadline, compatibleWith traitCollection: UITraitCollection = WKAppEnvironment.current.traitCollection, paletteColors: [UIColor]? = nil) -> UIImage? { let font = WKFont.for(font) @@ -156,6 +159,8 @@ public enum WKSFSymbolIcon { image = UIImage(systemName: "textformat.size", withConfiguration: configuration) case .textFormat: image = UIImage(systemName: "textformat", withConfiguration: configuration) + case .xMark: + image = UIImage(systemName: "xmark", withConfiguration: configuration) } image = image?.withRenderingMode(.alwaysTemplate) diff --git a/Components/Sources/Components/Utility/HTMLUtils.swift b/Components/Sources/Components/Utility/HTMLUtils.swift index 7c1793adc96..89f7b298e96 100644 --- a/Components/Sources/Components/Utility/HTMLUtils.swift +++ b/Components/Sources/Components/Utility/HTMLUtils.swift @@ -325,7 +325,13 @@ public struct HtmlUtils { attributedString.removeSubrange(tagRange) } } - + + public static func stringFromHTML(_ string: String) throws -> String { + let regex = try htmlTagRegex() + let cleanString = regex.stringByReplacingMatches(in: string, options: [], range: string.fullNSRange, withTemplate: "") + return cleanString + } + // MARK: - Shared - Private private static func htmlTagRegex() throws -> NSRegularExpression { diff --git a/Components/Tests/ComponentsTests/WKImageRecommendationsViewModelTests.swift b/Components/Tests/ComponentsTests/WKImageRecommendationsViewModelTests.swift index c9ac5a59ad4..1456ae46986 100644 --- a/Components/Tests/ComponentsTests/WKImageRecommendationsViewModelTests.swift +++ b/Components/Tests/ComponentsTests/WKImageRecommendationsViewModelTests.swift @@ -6,7 +6,7 @@ import XCTest final class WKImageRecommendationsViewModelTests: XCTestCase { private let csProject = WKProject.wikipedia(WKLanguage(languageCode: "cs", languageVariantCode: nil)) - private let localizedStrings = WKImageRecommendationsViewModel.LocalizedStrings(title: "Add image", viewArticle: "View article") + private let localizedStrings = WKImageRecommendationsViewModel.LocalizedStrings(title: "Add image", viewArticle: "View Article", bottomSheetTitle: "Add this image?", yesButtonTitle: "yes", noButtonTitle: "no", notSureButtonTitle: "not sure") override func setUpWithError() throws { WKDataEnvironment.current.mediaWikiService = WKMockGrowthTasksService() @@ -24,7 +24,7 @@ final class WKImageRecommendationsViewModelTests: XCTestCase { wait(for: [expectation], timeout: 3.0) - XCTAssertEqual(viewModel.recommendations.count, 10, "Unexpected image recommendations count.") + XCTAssertEqual(viewModel.imageRecommendations.count, 9, "Unexpected image recommendations count.") XCTAssertNotNil(viewModel.currentRecommendation, "currentRecommendation should not be nil after fetching recommendations") XCTAssertNotNil(viewModel.currentRecommendation?.articleSummary, "currentRecommendation.articleSummary should not be nil after fetching recommendations") } @@ -47,8 +47,8 @@ final class WKImageRecommendationsViewModelTests: XCTestCase { wait(for: [expectation2], timeout: 3.0) - XCTAssertEqual(viewModel.recommendations.count, 9, "Unexpected image recommendations count.") - + XCTAssertEqual(viewModel.imageRecommendations.count, 8, "Unexpected image recommendations count.") + XCTAssertNotNil(viewModel.currentRecommendation, "currentRecommendation should not be nil after next()") XCTAssertNotNil(viewModel.currentRecommendation?.articleSummary, "currentRecommendation.articleSummary should not be nil after next()") } diff --git a/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTaskAPIResponse.swift b/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTaskAPIResponse.swift deleted file mode 100644 index 8532b19f482..00000000000 --- a/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTaskAPIResponse.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -internal struct WKGrowthTaskAPIResponse: Codable { - - let batchcomplete: Bool - let `continue`: ContinueData - let growthtasks: GrowthTasks - let query: Query - - public struct QualityGateConfig: Codable { - let imageRecommendation: ImageRecommendation - let sectionImageRecomendation : SectionImageRecommendation - - enum CodingKeys: String, CodingKey { - case imageRecommendation = "image-recommendation" - case sectionImageRecomendation = "section-image-recommendation" - } - } - - struct ImageRecommendation: Codable { - let dailyLimit: Bool - let dailyCount: Int - } - - struct SectionImageRecommendation: Codable { - let dailyLimit: Bool - let dailyCount: Int - } - - struct Query: Codable { - let pages: [Page] - } - - struct Page: Codable { - let pageid: Int - let ns: Int - let title: String - let tasktype: String - let difficulty: String - let order: Int - let qualityGateIds: [String] - let qualityGateConfig: QualityGateConfig - let token: String - } - - struct GrowthTasks: Codable { - let totalCount: Int - let qualityGateConfig: QualityGateConfig - } - - struct ContinueData: Codable { - let ggtoffset: Int - let continueValue: String - - enum CodingKeys: String, CodingKey { - case ggtoffset - case continueValue = "continue" - } - } -} diff --git a/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTasksDataController.swift b/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTasksDataController.swift index d4c8b039316..253be0bfd68 100644 --- a/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTasksDataController.swift +++ b/WKData/Sources/WKData/Data Controllers/WKGrowthTasks/WKGrowthTasksDataController.swift @@ -11,84 +11,6 @@ public final class WKGrowthTasksDataController { // MARK: GET Methods - // TODO: to get more tasks, send used IDS to `ggtexcludepageids` parameter - // TODO: remove debug prints - // TODO: Might be able to remove this, data returns in getImageRecommendationsCombined - public func getGrowthAPITask(task: WKGrowthTaskType = .imageRecommendation, completion: @escaping (Result<[WKGrowthTask.Page], Error>) -> Void) { - - guard let service else { - completion(.failure(WKDataControllerError.mediaWikiServiceUnavailable)) - return - } - var pages: [WKGrowthTask.Page] = [] - - let parameters = [ "action": "query", - "generator": "growthtasks", - "formatversion": "2", - "format": "json", - "ggttasktypes": "\(task.rawValue)", - "ggtlimit": "10" - ] - - guard let url = URL.mediaWikiAPIURL(project: project) else { - completion(.failure(WKDataControllerError.failureCreatingRequestURL)) - return - } - - let request = WKMediaWikiServiceRequest(url: url, method: .GET, parameters: parameters) - service.performDecodableGET(request: request) { (result: Result) in - - switch result { - case .success(let response): - print(response) - pages.append(contentsOf: self.getTaskPages(from: response)) - completion(.success(pages)) - case .failure(let error): - print(error) - completion(.failure(error)) - } - } - } - - // TODO: Might be able to remove this, data returns in getImageRecommendationsCombined - public func getImageSuggestionData(pageIDs: [String], completion: @escaping (Result<[WKImageRecommendation.Page], Error>) -> Void) { - - let pipeEncodedPageIds = pageIDs.joined(separator: "|") - var recommendationsPerPage: [WKImageRecommendation.Page] = [] - - guard let service else { - completion(.failure(WKDataControllerError.mediaWikiServiceUnavailable)) - return - } - - let parameters = [ "action": "query", - "formatversion": "2", - "format": "json", - "prop":"growthimagesuggestiondata", - "pageids" : pipeEncodedPageIds, - "gisdtasktype": "image-recommendation" - ] - - guard let url = URL.mediaWikiAPIURL(project: project) else { - completion(.failure(WKDataControllerError.failureCreatingRequestURL)) - return - } - - let request = WKMediaWikiServiceRequest(url: url, method: .GET, parameters: parameters) - service.performDecodableGET(request: request) { (result: Result) in - - switch result { - case .success(let response): - print(response) - recommendationsPerPage.append(contentsOf: self.getImageSuggestions(from: response)) - completion(.success(recommendationsPerPage)) - case .failure(let error): - print(error) - completion(.failure(error)) - } - } - } - public func getImageRecommendationsCombined(completion: @escaping (Result<[WKImageRecommendation.Page], Error>) -> Void) { guard let service else { completion(.failure(WKDataControllerError.mediaWikiServiceUnavailable)) @@ -164,7 +86,7 @@ public final class WKGrowthTasksDataController { return suggestions } - fileprivate func getImageSuggestionData(from suggestion: WKImageRecommendationAPIResponse.GrowthImageSuggestionData) -> [WKImageRecommendation.ImageSuggestion] { + internal func getImageSuggestionData(from suggestion: WKImageRecommendationAPIResponse.GrowthImageSuggestionData) -> [WKImageRecommendation.ImageSuggestion] { var images: [WKImageRecommendation.ImageSuggestion] = [] for image in suggestion.images { @@ -186,19 +108,6 @@ public final class WKGrowthTasksDataController { return metadata } - fileprivate func getTaskPages(from response: WKGrowthTaskAPIResponse) -> [WKGrowthTask.Page] { - var pages: [WKGrowthTask.Page] = [] - - for page in response.query.pages { - let page = WKGrowthTask.Page( - pageid: page.pageid, - title: page.title, - tasktype: page.tasktype, - difficulty: page.difficulty) - pages.append(page) - } - return pages - } } // MARK: Types diff --git a/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendation.swift b/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendation.swift index 4d83cab0eb5..0ba9a2d55ec 100644 --- a/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendation.swift +++ b/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendation.swift @@ -7,31 +7,31 @@ public struct WKImageRecommendation { public struct Page { public let pageid: Int public let title: String - let growthimagesuggestiondata: [GrowthImageSuggestionData]? + public let growthimagesuggestiondata: [GrowthImageSuggestionData]? } public struct GrowthImageSuggestionData { let titleNamespace: Int let titleText: String - let images: [ImageSuggestion] + public let images: [ImageSuggestion] } public struct ImageSuggestion: Codable { - let image: String - let displayFilename: String + public let image: String + public let displayFilename: String let source: String let projects: [String] - let metadata: ImageMetadata + public let metadata: ImageMetadata } public struct ImageMetadata: Codable { let descriptionUrl: String - let thumbUrl: String - let fullUrl: String + public let thumbUrl: String + public let fullUrl: String let originalWidth: Int let originalHeight: Int let mediaType: String - let description: String? + public let description: String? let author: String? let license: String let date: String diff --git a/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendationData.swift b/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendationData.swift new file mode 100644 index 00000000000..e58ca3268e9 --- /dev/null +++ b/WKData/Sources/WKData/Models/Image Recommendations/WKImageRecommendationData.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct WKImageRecommendationData { + public let pageId: Int + public let image: String + public let filename: String + public let thumbUrl: String + public let fullUrl: String + public let description: String? + + public init(pageId: Int, image: String, filename: String, thumbUrl: String, fullUrl: String, description: String?) { + self.pageId = pageId + self.image = image + self.filename = filename + self.thumbUrl = thumbUrl + self.fullUrl = fullUrl + self.description = description + } +} diff --git a/WKData/Sources/WKDataMocks/Resources/growth-task-get.json b/WKData/Sources/WKDataMocks/Resources/growth-task-get.json deleted file mode 100644 index e384f5f1421..00000000000 --- a/WKData/Sources/WKDataMocks/Resources/growth-task-get.json +++ /dev/null @@ -1,288 +0,0 @@ -{ - "batchcomplete":true, - "continue":{ - "ggtoffset":10, - "continue":"ggtoffset||" - }, - "growthtasks":{ - "totalCount":17234, - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - } - }, - "query":{ - "pages":[ - { - "pageid":35571, - "ns":0, - "title":"Novela (právo)", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":6, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"unmj7838n633gn0gr9egdg64orsok59e" - }, - { - "pageid":136265, - "ns":0, - "title":"Překlep", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":0, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"inqsetig09sa37g2frgid17p78p3nt4v" - }, - { - "pageid":303931, - "ns":0, - "title":"Modal jazz", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":4, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"27rv2nb74oh4guqf7t94r73eg31aksa8" - }, - { - "pageid":754975, - "ns":0, - "title":"Mistrovství světa v moderní gymnastice", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":5, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"j2lbv6l6tevlfccu1te4uucu5u6dmn33" - }, - { - "pageid":819393, - "ns":0, - "title":"Korherrova zpráva", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":7, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"egg75hq87m2tunthqdc0p50r0j7moeni" - }, - { - "pageid":824001, - "ns":0, - "title":"Armeniakon", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":8, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"lprhfojv3755nc9f3p6fb8ud8n8f4kne" - }, - { - "pageid":1069254, - "ns":0, - "title":"Filmová filharmonie", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":3, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"hroe8jo6tocq2jog9nsmbpi2d6qgg8kn" - }, - { - "pageid":1262811, - "ns":0, - "title":"Partnerství veřejného a soukromého sektoru", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":1, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"q0c1ju2dg361695g4f1a7c82q38sti8e" - }, - { - "pageid":1537517, - "ns":0, - "title":"Zpětný ráz", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":2, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"5dig0u2t336lomh18nk561a371u8qfk9" - }, - { - "pageid":1766988, - "ns":0, - "title":"Žalm 3", - "tasktype":"image-recommendation", - "difficulty":"medium", - "order":9, - "qualityGateIds":[ - "dailyLimit" - ], - "qualityGateConfig":{ - "image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "section-image-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - }, - "link-recommendation":{ - "dailyLimit":false, - "dailyCount":0 - } - }, - "token":"840i7rfivml95vr50ranamugrvnvn5on" - } - ] - } -} diff --git a/WKData/Sources/WKDataMocks/Resources/growth-task-image-recs-get.json b/WKData/Sources/WKDataMocks/Resources/growth-task-image-recs-get.json deleted file mode 100644 index 6ee6d44a90f..00000000000 --- a/WKData/Sources/WKDataMocks/Resources/growth-task-image-recs-get.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "batchcomplete":true, - "query":{ - "pages":[ - { - "pageid":206400, - "ns":0, - "title":"Dialer", - "growthimagesuggestiondata":[ - { - "titleNamespace":0, - "titleText":"Dialer", - "images":[ - { - "image":"Modem_telefonico.jpg", - "displayFilename":"Modem telefonico.jpg", - "source":"wikipedia", - "projects":[ - "lmowiki" - ], - "metadata":{ - "descriptionUrl":"https://commons.wikimedia.org/wiki/File:Modem_telefonico.jpg", - "thumbUrl":"//upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Modem_telefonico.jpg/120px-Modem_telefonico.jpg", - "fullUrl":"//upload.wikimedia.org/wikipedia/commons/e/e9/Modem_telefonico.jpg", - "originalWidth":1200, - "originalHeight":1600, - "mustRender":false, - "isVectorized":false, - "mediaType":"BITMAP", - "description":"Modem telefonico 2WIRE Con servicio provisto por Telmex.", - "author":"MaryMozqueda / Mary Mozqueda", - "license":"CC BY 3.0", - "date":"2009-10-18", - "caption":null, - "categories":[ - "2Wire", - "ADSL modems", - "Modems", - "Telmex" - ], - "reason":"Utilizado en el mismo artículo en Wikipedia en lombardo.", - "contentLanguageName":"checo" - }, - "sectionNumber":null, - "sectionTitle":null - } - ], - "datasetId":"eee2129e-b5b4-11ee-89e4-bc97e15476f8" - } - ] - }, - { - "pageid":599768, - "ns":0, - "title":"Věrnostní program", - "growthimagesuggestiondata":[ - { - "titleNamespace":0, - "titleText":"Věrnostní program", - "images":[ - { - "image":"Capturar.JPG", - "displayFilename":"Capturar.JPG", - "source":"commons", - "projects":[ - "ptwiki" - ], - "metadata":{ - "descriptionUrl":"https://commons.wikimedia.org/wiki/File:Capturar.JPG", - "thumbUrl":"//upload.wikimedia.org/wikipedia/commons/thumb/6/62/Capturar.JPG/120px-Capturar.JPG", - "fullUrl":"//upload.wikimedia.org/wikipedia/commons/6/62/Capturar.JPG", - "originalWidth":767, - "originalHeight":383, - "mustRender":false, - "isVectorized":false, - "mediaType":"BITMAP", - "description":"Formas de acúmulo de pontos em programas de fidelidade.", - "author":"Franklin Jean Machado", - "license":"CC BY-SA 4.0", - "date":"2014-10-23 20:41:36", - "caption":null, - "categories":[ - "Customer loyalty programs" - ], - "reason":"Es una de las imágenes vinculadas al ítem de Wikidata de este artículo.", - "contentLanguageName":"checo" - }, - "sectionNumber":null, - "sectionTitle":null - } - ], - "datasetId":"eee2129e-b5b4-11ee-89e4-bc97e15476f8" - } - ] - }, - { - "pageid":1600967, - "ns":0, - "title":"Fixní měnový kurz", - "growthimagesuggestiondata":[ - { - "titleNamespace":0, - "titleText":"Fixní měnový kurz", - "images":[ - { - "image":"Currency_Exchange_regimes.png", - "displayFilename":"Currency Exchange regimes.png", - "source":"wikipedia", - "projects":[ - "ruwiki" - ], - "metadata":{ - "descriptionUrl":"https://commons.wikimedia.org/wiki/File:Currency_Exchange_regimes.png", - "thumbUrl":"//upload.wikimedia.org/wikipedia/commons/thumb/f/f6/Currency_Exchange_regimes.png/120px-Currency_Exchange_regimes.png", - "fullUrl":"//upload.wikimedia.org/wikipedia/commons/f/f6/Currency_Exchange_regimes.png", - "originalWidth":1357, - "originalHeight":628, - "mustRender":false, - "isVectorized":false, - "mediaType":"BITMAP", - "description":"dark green - free float regime, light green - Managed float regime, blue - different types of currency peg (Linked exchange rate, fixed exchange rate, Currency board, fixed/crawling peg, fixed/crawling band), red - direct usage of foreign currency; dependencies that use the currency of their \"mainland\" colored the same as the respective state.", - "author":"Alinor (talk)", - "license":"CC BY-SA 3.0", - "date":"2010-12-26 12:22", - "caption":null, - "categories":[ - "Economic maps of the world" - ], - "reason":"Utilizado en el mismo artículo en Wikipedia en ruso.", - "contentLanguageName":"checo" - }, - "sectionNumber":null, - "sectionTitle":null - } - ], - "datasetId":"eee2129e-b5b4-11ee-89e4-bc97e15476f8" - } - ] - } - ] - } - } \ No newline at end of file diff --git a/WKData/Tests/WKDataTests/WKGrowthTasksDataControllerTests.swift b/WKData/Tests/WKDataTests/WKGrowthTasksDataControllerTests.swift index 51f0c05cffe..2504d020320 100644 --- a/WKData/Tests/WKDataTests/WKGrowthTasksDataControllerTests.swift +++ b/WKData/Tests/WKDataTests/WKGrowthTasksDataControllerTests.swift @@ -12,128 +12,6 @@ final class WKGrowthTasksDataControllerTests: XCTestCase { WKDataEnvironment.current.mediaWikiService = WKMockGrowthTasksService() WKDataEnvironment.current.basicService = WKMockBasicService() } - - func testFetchGrowthTasks() { - let controller = WKGrowthTasksDataController(project: csProject) - let expectation = XCTestExpectation(description: "Fetch Growth Tasks") - - var tasksToTest: [WKGrowthTask.Page]? - controller.getGrowthAPITask { result in - switch result { - case .success(let response): - tasksToTest = response - case .failure(let error): - XCTFail("Failure fetching tasks: \(error)") - } - - expectation.fulfill() - } - - wait(for: [expectation], timeout: 10.0) - - XCTAssertTrue(tasksToTest != nil) - } - - func testParsingTasks() { - - let controller = WKGrowthTasksDataController(project: csProject) - var tasksToTest: [WKGrowthTask.Page]? - controller.getGrowthAPITask { result in - switch result { - case .success(let response): - tasksToTest = response - case .failure(let error): - XCTFail("Failure fetching tasks: \(error)") - } - } - - guard let tasksToTest else { - XCTFail("Failed to retrieve Growth Tasks") - return - } - - XCTAssertEqual(tasksToTest.count, 10, "Incorrect number of tasks") - - let firstTask = tasksToTest.first! - - XCTAssertEqual(firstTask.pageid, 35571, "Incorrect page ID") - XCTAssertEqual(firstTask.title, "Novela (právo)", "Incorrect title") - - } - - func testFetchImageRecommendationForTasks() { - - let controller = WKGrowthTasksDataController(project: csProject) - let expectation = XCTestExpectation(description: "Fetch Image Recommendations") - - var imageRecsToTest: [WKImageRecommendation.Page]? - - controller.getImageSuggestionData(pageIDs: ["1"]) { result in - switch result { - case .success(let response): - imageRecsToTest = response - case .failure(let error): - XCTFail("Failed to fetch Image Recommendations \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 10.0) - - XCTAssertTrue(imageRecsToTest != nil) - } - - func testParseImageReCommendations() { - - let controller = WKGrowthTasksDataController(project: csProject) - var imageRecsToTest: [WKImageRecommendation.Page]? - - controller.getImageSuggestionData(pageIDs: ["1"]) { result in - switch result { - case .success(let response): - imageRecsToTest = response - case .failure(let error): - XCTFail("Failed to fetch Image Recommendations \(error)") - } - } - - guard let imageRecsToTest else { - XCTFail("Failed to retrieve image recommendations") - return - } - - let firstImageRecommendation = imageRecsToTest.first - XCTAssertEqual(firstImageRecommendation?.pageid, 206400, "Incorrect page Id") - XCTAssertEqual(firstImageRecommendation?.title, "Dialer", "Incorrect page title") - XCTAssertEqual(firstImageRecommendation?.growthimagesuggestiondata?.count, 1, "Incorrect growth suggestion data count") - - - let firstImageSuggestionData = firstImageRecommendation?.growthimagesuggestiondata?.first - XCTAssertEqual(firstImageSuggestionData?.titleText, "Dialer", "Incorrect page title") - XCTAssertEqual(firstImageSuggestionData?.titleNamespace, 0, "Incorrect title namespace") - XCTAssertEqual(firstImageSuggestionData?.images.count, 1, "Incorrect images count") - - let firstImageData = firstImageSuggestionData?.images.first - XCTAssertEqual(firstImageData?.image, "Modem_telefonico.jpg", "Incorrect image file name") - XCTAssertEqual(firstImageData?.displayFilename, "Modem telefonico.jpg", "Incorrect image display name") - XCTAssertEqual(firstImageData?.source, "wikipedia", "Incorrect source name") - XCTAssertEqual(firstImageData?.projects.count, 1, "Incorrect project count") - - let imageMetadata = firstImageData?.metadata - XCTAssertEqual(imageMetadata?.descriptionUrl, "https://commons.wikimedia.org/wiki/File:Modem_telefonico.jpg", "Incorrect description URL") - XCTAssertEqual(imageMetadata?.thumbUrl, "//upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Modem_telefonico.jpg/120px-Modem_telefonico.jpg", "Incorrect thumb URL") - XCTAssertEqual(imageMetadata?.fullUrl, "//upload.wikimedia.org/wikipedia/commons/e/e9/Modem_telefonico.jpg", "Incorrect full URL") - XCTAssertEqual(imageMetadata?.originalWidth, 1200, "Incorrect width") - XCTAssertEqual(imageMetadata?.originalHeight, 1600, "Incorrect height") - XCTAssertEqual(imageMetadata?.mediaType, "BITMAP", "Incorrect mediatype") - XCTAssertEqual(imageMetadata?.description, "Modem telefonico 2WIRE Con servicio provisto por Telmex.", "Incorrect description") - XCTAssertEqual(imageMetadata?.author, "MaryMozqueda / Mary Mozqueda", "Incorrect author") - XCTAssertEqual(imageMetadata?.license, "CC BY 3.0", "Incorrect license") - XCTAssertEqual(imageMetadata?.date, "2009-10-18", "Incorrect date") - XCTAssertEqual(imageMetadata?.categories.count, 4, "Incorrect number of categories") - XCTAssertEqual(imageMetadata?.reason, "Utilizado en el mismo artículo en Wikipedia en lombardo.", "Incorrect reason") - XCTAssertEqual(imageMetadata?.contentLanguageName, "checo", "Incorrect content language name") - } func testFetchImageRecommendationCombinedForTasks() { diff --git a/WMF Framework/CommonStrings.swift b/WMF Framework/CommonStrings.swift index 7605175e3c9..3dd6ad89d61 100644 --- a/WMF Framework/CommonStrings.swift +++ b/WMF Framework/CommonStrings.swift @@ -634,8 +634,16 @@ public class CommonStrings: NSObject { public static let editPublishedToastTitle = WMFLocalizedString("editor-edit-published", value: "Your edit was published.", comment: "Title for alert informing that the user's new edit was successfully published.") @objc public static let suggestedEditsTitle = WMFLocalizedString("suggested-edits-title", value: "Suggested Edits", comment: "Title for the 'Suggested Edits' explore feed card") -} + // Image recommendations + + public static let addImageTitle = WMFLocalizedString("image-rec-title", value: "Add image", comment: "Title of the image recommendation view. Displayed in the navigation bar above an article summary.") + public static let viewArticle = WMFLocalizedString("image-rec-view-article", value: "View article", comment: "Button from an image recommendation article summary. Tapping the button displays the full article.") + public static let bottomSheetTitle = WMFLocalizedString("image-rec-add-image-title", value: "Add this image?", comment: "title for the add image suggestion view") + public static let noButtonTitle = WMFLocalizedString("image-recs-no-title", value: "No", comment: "Button title for discarding an image suggestion") + public static let yesButtonTitle = WMFLocalizedString("image-recs-yes-title", value: "Yes", comment: "Button title for accepting an image suggestion") + public static let notSureButtonTitle = WMFLocalizedString("image-recs-not-sure-title", value: "Not sure", comment: "Button title for skipping an image suggestion") +} // Language variant strings public extension CommonStrings { diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index bb36f877deb..fb4df8cf939 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -1091,7 +1091,21 @@ extension ExploreViewController: ExploreCardCollectionViewCellDelegate { extension ExploreViewController { @objc func userDidTapNotificationsCenter() { - notificationsCenterPresentationDelegate?.userDidTapNotificationsCenter(from: self) + // TODO: Temp Code until we get Explore Feed card in + if FeatureFlags.needsImageRecommendations { + + guard let siteURL = dataStore.languageLinkController.appLanguage?.siteURL, + let project = WikimediaProject(siteURL: siteURL)?.wkProject else { + return + } + + let localizedStrings = WKImageRecommendationsViewModel.LocalizedStrings(title: CommonStrings.addImageTitle, viewArticle: CommonStrings.viewArticle, bottomSheetTitle: CommonStrings.bottomSheetTitle, yesButtonTitle: CommonStrings.yesButtonTitle, noButtonTitle: CommonStrings.noButtonTitle, notSureButtonTitle: CommonStrings.notSureButtonTitle) + let viewModel = WKImageRecommendationsViewModel(project: project, localizedStrings: localizedStrings) + let imageRecommendationsViewController = WKImageRecommendationsViewController(viewModel: viewModel, delegate: self) + navigationController?.pushViewController(imageRecommendationsViewController, animated: true) + } else { + notificationsCenterPresentationDelegate?.userDidTapNotificationsCenter(from: self) + } } @objc func pushNotificationBannerDidDisplayInForeground(_ notification: Notification) { diff --git a/Wikipedia/Code/WMFContentGroup+DetailViewControllers.swift b/Wikipedia/Code/WMFContentGroup+DetailViewControllers.swift index 8bb3c231f33..55223efc991 100644 --- a/Wikipedia/Code/WMFContentGroup+DetailViewControllers.swift +++ b/Wikipedia/Code/WMFContentGroup+DetailViewControllers.swift @@ -70,10 +70,8 @@ extension WMFContentGroup { let imageRecDelegate = imageRecDelegate else { return nil } - - let title = WMFLocalizedString("image-rec-title", value: "Add image", comment: "Title of the image recommendation view. Displayed in the navigation bar above an article summary.") - let viewArticle = WMFLocalizedString("image-rec-view-article", value: "View article", comment: "Button from an image recommendation article summary. Tapping the button displays the full article.") - let localizedStrings = WKImageRecommendationsViewModel.LocalizedStrings(title: title, viewArticle: viewArticle) + + let localizedStrings = WKImageRecommendationsViewModel.LocalizedStrings(title: CommonStrings.addImageTitle, viewArticle: CommonStrings.viewArticle, bottomSheetTitle: CommonStrings.bottomSheetTitle, yesButtonTitle: CommonStrings.yesButtonTitle, noButtonTitle: CommonStrings.noButtonTitle, notSureButtonTitle: CommonStrings.notSureButtonTitle) let viewModel = WKImageRecommendationsViewModel(project: project, localizedStrings: localizedStrings) let imageRecommendationsViewController = WKImageRecommendationsViewController(viewModel: viewModel, delegate: imageRecDelegate) return imageRecommendationsViewController @@ -91,3 +89,4 @@ extension WMFContentGroup { return vc } } + diff --git a/Wikipedia/Localizations/en.lproj/Localizable.strings b/Wikipedia/Localizations/en.lproj/Localizable.strings index f67cca7fce9..193a04d8a63 100644 --- a/Wikipedia/Localizations/en.lproj/Localizable.strings +++ b/Wikipedia/Localizations/en.lproj/Localizable.strings @@ -588,8 +588,12 @@ "icon-shortcut-random-title" = "Random article"; "icon-shortcut-search-title" = "Search Wikipedia"; "image-gallery-unknown-owner" = "Author unknown."; +"image-rec-add-image-title" = "Add this image?"; "image-rec-title" = "Add image"; "image-rec-view-article" = "View article"; +"image-recs-no-title" = "No"; +"image-recs-not-sure-title" = "Not sure"; +"image-recs-yes-title" = "Yes"; "import-shared-reading-list-default-title" = "My Reading List"; "import-shared-reading-list-survey-prompt-button-cancel" = "Not now"; "import-shared-reading-list-survey-prompt-button-take-survey" = "Take survey"; diff --git a/Wikipedia/Localizations/qqq.lproj/Localizable.strings b/Wikipedia/Localizations/qqq.lproj/Localizable.strings index 5aebfcf56eb..0833275e09e 100644 --- a/Wikipedia/Localizations/qqq.lproj/Localizable.strings +++ b/Wikipedia/Localizations/qqq.lproj/Localizable.strings @@ -588,8 +588,12 @@ "icon-shortcut-random-title" = "Title for app icon force touch shortcut to quickly open a random article. {{Identical|Random article}}"; "icon-shortcut-search-title" = "Title for app icon force touch shortcut to quickly open the search interface."; "image-gallery-unknown-owner" = "Fallback text for when an item in the image gallery doesn't have a specified owner."; +"image-rec-add-image-title" = "title for the add image suggestion view"; "image-rec-title" = "Title of the image recommendation view. Displayed in the navigation bar above an article summary."; "image-rec-view-article" = "Button from an image recommendation article summary. Tapping the button displays the full article."; +"image-recs-no-title" = "Button title for discarding an image suggestion"; +"image-recs-not-sure-title" = "Button title for skipping an image suggestion"; +"image-recs-yes-title" = "Button title for accepting an image suggestion"; "import-shared-reading-list-default-title" = "Default title of a reading list imported through a shared link."; "import-shared-reading-list-survey-prompt-button-cancel" = "Title of cancel button on import shared reading list survey prompt, which dismisses the prompt."; "import-shared-reading-list-survey-prompt-button-take-survey" = "Title of action button on import reading list survey prompt, which takes user to external survey."; diff --git a/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings b/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings index c7c58320c41..6f9c8decd6d 100644 Binary files a/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings and b/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings differ