From 458ff729c7a8f0c1ae3103ac93fd76c744921b56 Mon Sep 17 00:00:00 2001 From: Grey Olson Date: Sat, 24 Aug 2024 21:52:50 -0400 Subject: [PATCH] Refactor out ArticleViewController into extensions --- Wikipedia.xcodeproj/project.pbxproj | 42 +- ...leViewController + AltTextExperiment.swift | 89 ++ ...cleViewController+ArticleAsLivingDoc.swift | 125 ++ .../ArticleViewController+ArticleLoad.swift | 232 ++++ ...icleViewController+ArticleLoadErrors.swift | 105 ++ ...ewController+ArticleStateRestoration.swift | 67 + .../Code/ArticleViewController+Editing.swift | 4 +- .../ArticleViewController+LeadImage.swift | 40 + .../Code/ArticleViewController+Loading.swift | 110 ++ .../Code/ArticleViewController+Media.swift | 2 +- .../ArticleViewController+Notifications.swift | 182 +++ .../Code/ArticleViewController+Refresh.swift | 83 ++ ...iewController+ScrollRestorationState.swift | 64 + Wikipedia/Code/ArticleViewController.swift | 1108 +---------------- Wikipedia/Code/ExploreViewController.swift | 8 +- 15 files changed, 1181 insertions(+), 1080 deletions(-) create mode 100644 Wikipedia/Code/ArticleViewController + AltTextExperiment.swift create mode 100644 Wikipedia/Code/ArticleViewController+ArticleAsLivingDoc.swift create mode 100644 Wikipedia/Code/ArticleViewController+ArticleLoad.swift create mode 100644 Wikipedia/Code/ArticleViewController+ArticleLoadErrors.swift create mode 100644 Wikipedia/Code/ArticleViewController+ArticleStateRestoration.swift create mode 100644 Wikipedia/Code/ArticleViewController+LeadImage.swift create mode 100644 Wikipedia/Code/ArticleViewController+Loading.swift create mode 100644 Wikipedia/Code/ArticleViewController+Notifications.swift create mode 100644 Wikipedia/Code/ArticleViewController+Refresh.swift create mode 100644 Wikipedia/Code/ArticleViewController+ScrollRestorationState.swift diff --git a/Wikipedia.xcodeproj/project.pbxproj b/Wikipedia.xcodeproj/project.pbxproj index bfdba8642e3..4f76d7c59bd 100644 --- a/Wikipedia.xcodeproj/project.pbxproj +++ b/Wikipedia.xcodeproj/project.pbxproj @@ -385,6 +385,16 @@ 0EF8634E1C19E02700006D2D /* WMFEmptyView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0EF8634D1C19E02700006D2D /* WMFEmptyView.xib */; }; 0EF863511C19E4F100006D2D /* WMFEmptyView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0EF863501C19E4F100006D2D /* WMFEmptyView.m */; }; 19A175F095F5197BA20EA8BA /* NSUserActivity+WMFExtensionsTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 19A172FA6AE61E76FCEF4259 /* NSUserActivity+WMFExtensionsTest.m */; }; + 375B0AC92C7AC0DE00BDE00A /* ArticleViewController + AltTextExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0AC82C7AC0DE00BDE00A /* ArticleViewController + AltTextExperiment.swift */; }; + 375B0ACB2C7AC27E00BDE00A /* ArticleViewController+LeadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0ACA2C7AC27E00BDE00A /* ArticleViewController+LeadImage.swift */; }; + 375B0ACD2C7AC2BA00BDE00A /* ArticleViewController+ScrollRestorationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0ACC2C7AC2BA00BDE00A /* ArticleViewController+ScrollRestorationState.swift */; }; + 375B0ACF2C7AC35200BDE00A /* ArticleViewController+ArticleLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0ACE2C7AC35200BDE00A /* ArticleViewController+ArticleLoad.swift */; }; + 375B0AD12C7AC37800BDE00A /* ArticleViewController+Loading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0AD02C7AC37800BDE00A /* ArticleViewController+Loading.swift */; }; + 375B0AD32C7AC39C00BDE00A /* ArticleViewController+ArticleAsLivingDoc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0AD22C7AC39C00BDE00A /* ArticleViewController+ArticleAsLivingDoc.swift */; }; + 375B0AD52C7AC43600BDE00A /* ArticleViewController+ArticleLoadErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0AD42C7AC43600BDE00A /* ArticleViewController+ArticleLoadErrors.swift */; }; + 375B0AD72C7AC46000BDE00A /* ArticleViewController+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0AD62C7AC46000BDE00A /* ArticleViewController+Notifications.swift */; }; + 375B0AD92C7AC4DB00BDE00A /* ArticleViewController+Refresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0AD82C7AC4DB00BDE00A /* ArticleViewController+Refresh.swift */; }; + 375B0ADD2C7AC57B00BDE00A /* ArticleViewController+ArticleStateRestoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375B0ADC2C7AC57B00BDE00A /* ArticleViewController+ArticleStateRestoration.swift */; }; 41CCB67421CC1F9700206B47 /* SavedArticlesCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41CCB67321CC1F9700206B47 /* SavedArticlesCollectionViewController.swift */; }; 41CCB67521CC1F9700206B47 /* SavedArticlesCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41CCB67321CC1F9700206B47 /* SavedArticlesCollectionViewController.swift */; }; 41CCB67621CC1F9700206B47 /* SavedArticlesCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41CCB67321CC1F9700206B47 /* SavedArticlesCollectionViewController.swift */; }; @@ -3250,6 +3260,16 @@ 0EF8634F1C19E4F100006D2D /* WMFEmptyView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = WMFEmptyView.h; path = Wikipedia/Code/WMFEmptyView.h; sourceTree = SOURCE_ROOT; }; 0EF863501C19E4F100006D2D /* WMFEmptyView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = WMFEmptyView.m; path = Wikipedia/Code/WMFEmptyView.m; sourceTree = SOURCE_ROOT; }; 19A172FA6AE61E76FCEF4259 /* NSUserActivity+WMFExtensionsTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserActivity+WMFExtensionsTest.m"; sourceTree = ""; }; + 375B0AC82C7AC0DE00BDE00A /* ArticleViewController + AltTextExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController + AltTextExperiment.swift"; sourceTree = ""; }; + 375B0ACA2C7AC27E00BDE00A /* ArticleViewController+LeadImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+LeadImage.swift"; sourceTree = ""; }; + 375B0ACC2C7AC2BA00BDE00A /* ArticleViewController+ScrollRestorationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+ScrollRestorationState.swift"; sourceTree = ""; }; + 375B0ACE2C7AC35200BDE00A /* ArticleViewController+ArticleLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+ArticleLoad.swift"; sourceTree = ""; }; + 375B0AD02C7AC37800BDE00A /* ArticleViewController+Loading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+Loading.swift"; sourceTree = ""; }; + 375B0AD22C7AC39C00BDE00A /* ArticleViewController+ArticleAsLivingDoc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+ArticleAsLivingDoc.swift"; sourceTree = ""; }; + 375B0AD42C7AC43600BDE00A /* ArticleViewController+ArticleLoadErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+ArticleLoadErrors.swift"; sourceTree = ""; }; + 375B0AD62C7AC46000BDE00A /* ArticleViewController+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+Notifications.swift"; sourceTree = ""; }; + 375B0AD82C7AC4DB00BDE00A /* ArticleViewController+Refresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+Refresh.swift"; sourceTree = ""; }; + 375B0ADC2C7AC57B00BDE00A /* ArticleViewController+ArticleStateRestoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArticleViewController+ArticleStateRestoration.swift"; sourceTree = ""; }; 41CCB67321CC1F9700206B47 /* SavedArticlesCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedArticlesCollectionViewController.swift; sourceTree = ""; }; 41FCAA3521C844CB001D8411 /* ReadingListEntryCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingListEntryCollectionViewController.swift; sourceTree = ""; }; 533AB8AD259792A9003A43D9 /* wikipedia-language-variants.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "wikipedia-language-variants.json"; sourceTree = ""; }; @@ -6498,8 +6518,18 @@ BAA0D91B1F4F165A00091284 /* PageIssuesTableViewController.swift */, 830AD2B824D1D615003EEFE6 /* WebPageUserScript.swift */, 67DC5BEE23A1427C00B03A84 /* ActionHandlerScript.swift */, + 375B0ACE2C7AC35200BDE00A /* ArticleViewController+ArticleLoad.swift */, + 375B0AD42C7AC43600BDE00A /* ArticleViewController+ArticleLoadErrors.swift */, 00A7946A245CA4E60063BA18 /* ArticleSurveyTimerController.swift */, 67DC5BE223A017CA00B03A84 /* ArticleViewController.swift */, + 375B0AD82C7AC4DB00BDE00A /* ArticleViewController+Refresh.swift */, + 375B0AD62C7AC46000BDE00A /* ArticleViewController+Notifications.swift */, + 375B0ADC2C7AC57B00BDE00A /* ArticleViewController+ArticleStateRestoration.swift */, + 375B0AD22C7AC39C00BDE00A /* ArticleViewController+ArticleAsLivingDoc.swift */, + 375B0AD02C7AC37800BDE00A /* ArticleViewController+Loading.swift */, + 375B0ACC2C7AC2BA00BDE00A /* ArticleViewController+ScrollRestorationState.swift */, + 375B0ACA2C7AC27E00BDE00A /* ArticleViewController+LeadImage.swift */, + 375B0AC82C7AC0DE00BDE00A /* ArticleViewController + AltTextExperiment.swift */, D8A47C8E23D7338C002AA823 /* ArticleViewController+TableOfContents.swift */, 83B01F7123DA5327001185F4 /* ArticleViewController+ArticleWebMessageHandling.swift */, 83B01F7623DA5348001185F4 /* ArticleViewController+ArticleToolbarHandling.swift */, @@ -6523,10 +6553,10 @@ 67ADEE9523A2CFFB0000CAF7 /* ArticleWebMessagingController.swift */, 83DAA9AF23FEB611002D5716 /* ReferenceBackLinksViewController.swift */, FFD7B85524B3B384005C2471 /* ReferenceBackLinksViewControllerDelegate.swift */, + 67033E182A61DC3700896852 /* ArticleViewController+Watchlist.swift */, D8E6FF7524058AC600686272 /* WMFWebView.h */, D8E6FF7624058AC600686272 /* WMFWebView.m */, B0DE92301D6E3A2000EC76A7 /* UIBarButtonItem Popover Message */, - 67033E182A61DC3700896852 /* ArticleViewController+Watchlist.swift */, 0E9DFEAB1BDEB82E0032606E /* Networking */, BC45D5A01C330393007C72F3 /* Sharing */, 04CCA0BD19830837000E982A /* References */, @@ -10133,6 +10163,7 @@ B083375D1DB16A09002860D2 /* WMFWelcomePanelViewController.swift in Sources */, 67C6F7A627E8CB9000B9C864 /* NotificationsCenterCommonViewModel+TextExtensions.swift in Sources */, 7A1469C5220BC223000A20F1 /* EditHintController.swift in Sources */, + 375B0AD12C7AC37800BDE00A /* ArticleViewController+Loading.swift in Sources */, 007CCF0126D5A10200D5EA7C /* NotificationsCenterViewController.swift in Sources */, 673DF4932AB09DFE00B247E5 /* UIViewController+DonateHelpers.swift in Sources */, 7A610CB7220A30C900C266AE /* HintViewController.swift in Sources */, @@ -10187,6 +10218,7 @@ BCA15AE51C0E213300D0A3EA /* LoggingDefaults.swift in Sources */, 003AD72E2979C512005BDB90 /* EditNoticesViewModel.swift in Sources */, 005E004128DE1F2800721584 /* TalkPageCoffeeRollViewModel.swift in Sources */, + 375B0ACD2C7AC2BA00BDE00A /* ArticleViewController+ScrollRestorationState.swift in Sources */, 67B5334128416C0D00C33E13 /* UserDataExportCache.swift in Sources */, 7A741DCA207FB9CC00CBAAE2 /* SearchBarExtendedViewController.swift in Sources */, 6782DBF6234537CF003FA21B /* DiffHeaderExtendedView.swift in Sources */, @@ -10208,6 +10240,7 @@ 6741245027E97DBC0071177D /* NotificationsCenterDetailViewModel+ActionExtensions.swift in Sources */, 00E2EA8926E28A9700B1A741 /* NotificationsCenterCellStyle.swift in Sources */, B0C6BE571E4526A40033BD6E /* WMFChangePasswordViewController.swift in Sources */, + 375B0AD32C7AC39C00BDE00A /* ArticleViewController+ArticleAsLivingDoc.swift in Sources */, B0421AA2206991F500C22630 /* SavedTabBarItemProgressBadgeManager.swift in Sources */, B0F4761B21F921D300C4E254 /* EditSummaryViewController.swift in Sources */, B389CFCE1E6F238300483C06 /* WMFMapsActivity.swift in Sources */, @@ -10283,6 +10316,7 @@ 672D69A9273ACAA100B123B3 /* UITabBarAppearance+Extensions.swift in Sources */, B0E8036D1C0CD98B0065EBC0 /* TableOfContentsViewController.swift in Sources */, 7A9F2776225E3462002119B3 /* InsertMediaSearchResultsCollectionViewController.swift in Sources */, + 375B0ACB2C7AC27E00BDE00A /* ArticleViewController+LeadImage.swift in Sources */, 0036C8B3282C2AAA00EADB35 /* Notification+NotificationsCenter.swift in Sources */, B0DF6F811CFE1D0B0046E507 /* WKWebView+WMFWebViewControllerJavascript.m in Sources */, 0EC044791C7917860033D773 /* WMFArticleTextActivitySource.m in Sources */, @@ -10313,6 +10347,7 @@ 00FCCBCA2900848300C9ECD2 /* TalkPageViewController+FindInPage.swift in Sources */, 7A4FE53F1FA00AEF009FA199 /* ArticlePeekPreviewViewController.swift in Sources */, 8382F8D920D9371E00AE5250 /* WMFContentGroup+DetailViewControllers.swift in Sources */, + 375B0AD52C7AC43600BDE00A /* ArticleViewController+ArticleLoadErrors.swift in Sources */, 00EBB7CC27D6A86A002025AC /* SettingsPresentationDelegate.swift in Sources */, D8B1668C1FD97FE000097D8B /* WMFViewController.m in Sources */, D82E95851F16502E007BD960 /* WMFLanguagesViewController.m in Sources */, @@ -10338,6 +10373,7 @@ D8A47C8523D7259A002AA823 /* NoIntrinsicContentSizeImageView.swift in Sources */, D8E27BA11F82B38100F9D2B3 /* RMessageView.m in Sources */, B0C6BE481E428C940033BD6E /* WMFAccountCreator.swift in Sources */, + 375B0AD92C7AC4DB00BDE00A /* ArticleViewController+Refresh.swift in Sources */, 7ABAD6B420338CFB006A364C /* ReadingListDetailUnderBarViewController.swift in Sources */, B066F0D51E513DAA00A199F8 /* UIViewController+WMFHideKeyboard.swift in Sources */, 832289DB1F7291BA0081A5FB /* SizeThatFitsReusableView.swift in Sources */, @@ -10457,6 +10493,7 @@ 8320331B22B90528004A9EDA /* NavigationStateController.swift in Sources */, D8421B53203CC8420040F50B /* DebugReadingListsViewController.swift in Sources */, 7A71567922699D5B0066FEC4 /* InsertMediaLabelTableFooterView.swift in Sources */, + 375B0AC92C7AC0DE00BDE00A /* ArticleViewController + AltTextExperiment.swift in Sources */, 0042812525E6E841004945B3 /* NYTPhotoTransitionController.m in Sources */, 0EF2249A1CC5536200FDF78E /* WMFLanguageCell.m in Sources */, 6771298F24FF76AC00E89CA5 /* ArticleAsLivingDocViewController.swift in Sources */, @@ -10519,8 +10556,10 @@ B0E806C41C0CEB380065EBC0 /* WMFSettingsViewController.m in Sources */, 7A70797D223AB69000A2BDFC /* WelcomePanelLabelContentViewController.swift in Sources */, B0E8054E1C0CE0DC0065EBC0 /* UIScrollView+WMFContentOffsetUtils.m in Sources */, + 375B0ACF2C7AC35200BDE00A /* ArticleViewController+ArticleLoad.swift in Sources */, B0524AF12144D7BE00D8FD8D /* DescriptionHelpViewController.swift in Sources */, 679A24082968E0D0008D7686 /* ShiftingScrollView.swift in Sources */, + 375B0ADD2C7AC57B00BDE00A /* ArticleViewController+ArticleStateRestoration.swift in Sources */, 0E9B9E331CBF3225001E4C3C /* WMFImageGalleryDetailOverlayView.m in Sources */, 7A998AC11FE20F3B007FE06E /* CollectionViewEditControllerNavigationDelegate+Extensions.swift in Sources */, D81E5F881E5F2C8400E1A80C /* UIApplication+SystemSettings.swift in Sources */, @@ -10557,6 +10596,7 @@ 677D8A602B75948200DD9B7D /* EditorButton.swift in Sources */, 67CEF26F2351113000D5CA6C /* DiffController.swift in Sources */, B083371E1DADB251002860D2 /* WMFWelcomePageViewController.swift in Sources */, + 375B0AD72C7AC46000BDE00A /* ArticleViewController+Notifications.swift in Sources */, 6782DBBB2343B861003FA21B /* DiffListViewController.swift in Sources */, 67C8E8022AC711E1003FAB48 /* WMFSettingsViewController+DonateHelpers.swift in Sources */, 7A29A5C81F6C405900E8F42B /* HistoryViewController.swift in Sources */, diff --git a/Wikipedia/Code/ArticleViewController + AltTextExperiment.swift b/Wikipedia/Code/ArticleViewController + AltTextExperiment.swift new file mode 100644 index 00000000000..0781db0a054 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController + AltTextExperiment.swift @@ -0,0 +1,89 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: - ArticleViewController + AltTextExperiment + +extension ArticleViewController { + func setup() { + if let altTextExperimentViewModel { + self.navigationItem.titleView = nil + self.title = altTextExperimentViewModel.localizedStrings.articleNavigationBarTitle + + let rightBarButtonItem = + UIBarButtonItem( + image: WMFSFSymbolIcon.for(symbol: .ellipsisCircle), + primaryAction: nil, + menu: overflowMenu + ) + navigationItem.rightBarButtonItem = rightBarButtonItem + rightBarButtonItem.tintColor = theme.colors.link + + self.navigationBar.updateNavigationItems() + } else { + setupWButton() + setupSearchButton() + } + + addNotificationHandlers() + setupWebView() + setupMessagingController() + } + + private var overflowMenu: UIMenu { + let learnMore = UIAction(title: CommonStrings.learnMoreTitle(), image: WMFSFSymbolIcon.for(symbol: .infoCircle), handler: { [weak self] _ in + if let project = self?.project { + EditInteractionFunnel.shared.logAltTextEditingInterfaceOverflowLearnMore(project: project) + } + self?.goToFAQ() + }) + + let tutorial = UIAction(title: CommonStrings.tutorialTitle, image: WMFSFSymbolIcon.for(symbol: .lightbulbMin), handler: { [weak self] _ in + if let project = self?.project { + EditInteractionFunnel.shared.logAltTextEditingInterfaceOverflowTutorial(project: project) + } + self?.showTutorial() + }) + + let reportIssues = UIAction(title: CommonStrings.problemWithFeatureTitle, image: WMFSFSymbolIcon.for(symbol: .flag), handler: { [weak self] _ in + if let project = self?.project { + EditInteractionFunnel.shared.logAltTextEditingInterfaceOverflowReport(project: project) + } + self?.reportIssue() + }) + + let menuItems: [UIMenuElement] = [learnMore, tutorial, reportIssues] + + return UIMenu(title: String(), children: menuItems) + } + + private func goToFAQ() { + if let altTextExperimentViewModel { + isReturningFromFAQ = true + navigate(to: altTextExperimentViewModel.learnMoreURL, useSafari: false) + } + } + + private func showTutorial() { + presentAltTextTooltipsIfNecessary(force: true) + } + + private func reportIssue() { + let emailAddress = "ios-support@wikimedia.org" + let emailSubject = WMFLocalizedString("alt-text-email-title", value: "Issue Report - Alt Text Feature", comment: "Title text for Alt Text pre-filled issue report email") + let emailBodyLine1 = WMFLocalizedString("alt-text-email-first-line", value: "I've encountered a problem with the Alt Text feature:", comment: "Text for Alt Text pre-filled issue report email") + let emailBodyLine2 = WMFLocalizedString("alt-text-email-second-line", value: "- [Describe specific problem]", comment: "Text for Alt Text pre-filled issue report email. This text is intended to be replaced by the user with a description of the problem they are encountering") + let emailBodyLine3 = WMFLocalizedString("alt-text-email-third-line", value: "The behavior I would like to see is:", comment: "Text for Alt Text pre-filled issue report email") + let emailBodyLine4 = WMFLocalizedString("alt-text-email-fourth-line", value: "- [Describe proposed solution]", comment: "Text for Alt Text pre-filled issue report email. This text is intended to be replaced by the user with a description of a user suggested solution") + let emailBodyLine5 = WMFLocalizedString("alt-text-email-fifth-line", value: "[Screenshots or Links]", comment: "Text for Alt Text pre-filled issue report email. This text is intended to be replaced by the user with a screenshot or link.") + let emailBody = "\(emailBodyLine1)\n\n\(emailBodyLine2)\n\n\(emailBodyLine3)\n\n\(emailBodyLine4)\n\n\(emailBodyLine5)" + let mailto = "mailto:\(emailAddress)?subject=\(emailSubject)&body=\(emailBody)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + + guard let encodedMailto = mailto, let mailtoURL = URL(string: encodedMailto), UIApplication.shared.canOpenURL(mailtoURL) else { + WMFAlertManager.sharedInstance.showErrorAlertWithMessage(CommonStrings.noEmailClient, sticky: false, dismissPreviousAlerts: false) + return + } + UIApplication.shared.open(mailtoURL) + } +} diff --git a/Wikipedia/Code/ArticleViewController+ArticleAsLivingDoc.swift b/Wikipedia/Code/ArticleViewController+ArticleAsLivingDoc.swift new file mode 100644 index 00000000000..89f4625f93f --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+ArticleAsLivingDoc.swift @@ -0,0 +1,125 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: Article As Living Doc Protocols +extension ArticleViewController: ArticleAsLivingDocViewControllerDelegate { + func livingDocViewWillPush() { + surveyTimerController?.livingDocViewWillPush(withState: state) + } + + func livingDocViewWillAppear() { + surveyTimerController?.livingDocViewWillAppear(withState: state) + } + + var articleAsLivingDocViewModel: ArticleAsLivingDocViewModel? { + return articleAsLivingDocController.articleAsLivingDocViewModel + } + + func fetchNextPage(nextRvStartId: UInt, theme: Theme) { + articleAsLivingDocController.fetchNextPage(nextRvStartId: nextRvStartId, traitCollection: traitCollection, theme: theme) + } + + var isFetchingAdditionalPages: Bool { + return articleAsLivingDocController.isFetchingAdditionalPages + } +} + +extension ArticleViewController: ArticleAsLivingDocControllerDelegate { + var abTestsController: ABTestsController { + return dataStore.abTestsController + } + + var isInValidSurveyCampaignAndArticleList: Bool { + surveyAnnouncementResult != nil + } + + func extendTimerForPresentingModal() { + surveyTimerController?.extendTimer() + } +} + +extension ArticleViewController: ArticleSurveyTimerControllerDelegate { + var displayDelay: TimeInterval? { + surveyAnnouncementResult?.displayDelay + } + + var shouldAttemptToShowArticleAsLivingDoc: Bool { + return articleAsLivingDocController.shouldAttemptToShowArticleAsLivingDoc + } + + var userHasSeenSurveyPrompt: Bool { + + guard let identifier = surveyAnnouncementResult?.campaignIdentifier else { + return false + } + + return SurveyAnnouncementsController.shared.userHasSeenSurveyPrompt(forCampaignIdentifier: identifier) + } + + var shouldShowArticleAsLivingDoc: Bool { + return articleAsLivingDocController.shouldShowArticleAsLivingDoc + } + + var livingDocSurveyLinkState: ArticleAsLivingDocSurveyLinkState { + return articleAsLivingDocController.surveyLinkState + } + + +} + +extension ArticleViewController: UISheetPresentationControllerDelegate { + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + + guard altTextExperimentViewModel != nil else { + return + } + + let oldContentInset = webView.scrollView.contentInset + + if let selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier { + switch selectedDetentIdentifier { + case .medium, .large: + webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: view.bounds.height * 0.65, right: oldContentInset.right) + default: + logMinimized() + webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: 75, right: oldContentInset.right) + } + } + } + + private func logMinimized() { + if let project = project { + EditInteractionFunnel.shared.logAltTextInputDidMinimize(project: project) + } + } +} + +extension ArticleViewController: WMFAltTextExperimentModalSheetLoggingDelegate { + + func didTriggerCharacterWarning() { + if let project = project { + EditInteractionFunnel.shared.logAltTextInputDidTriggerWarning(project: project) + } + } + + func didTapFileName() { + if let project = project { + EditInteractionFunnel.shared.logAltTextInputDidTapFileName(project: project) + } + } + + func didAppear() { + if let project = project { + EditInteractionFunnel.shared.logAltTextInputDidAppear(project: project) + } + } + + func didFocusTextView() { + if let project = project { + EditInteractionFunnel.shared.logAltTextInputDidFocus(project: project) + } + } +} + diff --git a/Wikipedia/Code/ArticleViewController+ArticleLoad.swift b/Wikipedia/Code/ArticleViewController+ArticleLoad.swift new file mode 100644 index 00000000000..c544f276a06 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+ArticleLoad.swift @@ -0,0 +1,232 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: - ArticleViewController + ArticleLoad +extension ArticleViewController { + func loadIfNecessary() { + guard state == .initial else { + return + } + load() + } + + func load() { + state = .loading + + setupPageContentServiceJavaScriptInterface { + let cachePolicy: WMFCachePolicy? = self.isRestoringState ? .foundation(.returnCacheDataElseLoad) : nil + + let revisionID = self.altTextExperimentViewModel != nil ? self.altTextExperimentViewModel?.lastRevisionID : nil + + self.loadPage(cachePolicy: cachePolicy, revisionID: revisionID) + } + } + + /// Waits for the article and article summary to finish loading (or re-loading) and performs post load actions + internal func setupArticleLoadWaitGroup() { + assert(Thread.isMainThread) + + guard articleLoadWaitGroup == nil else { + return + } + + articleLoadWaitGroup = DispatchGroup() + articleLoadWaitGroup?.enter() // will leave on setup complete + articleLoadWaitGroup?.notify(queue: DispatchQueue.main) { [weak self] in + + guard let self = self else { + return + } + + self.articleAsLivingDocController.articleContentFinishedLoading() + + if altTextExperimentViewModel != nil { + self.setupForAltTextExperiment() + } else { + self.setupFooter() + } + + self.shareIfNecessary() + self.restoreScrollStateIfNecessary() + self.articleLoadWaitGroup = nil + } + } + + private func setupForAltTextExperiment() { + + guard let altTextExperimentViewModel, + altTextBottomSheetViewModel != nil else { + return + } + + let oldContentInset = webView.scrollView.contentInset + webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: view.bounds.height * 0.65, right: oldContentInset.right) + messagingController.hideEditPencils() + messagingController.scrollToNewImage(filename: altTextExperimentViewModel.filename) + + presentAltTextModalSheet() + } + + func presentAltTextModalSheet() { + + guard altTextExperimentViewModel != nil, + let altTextBottomSheetViewModel else { + return + } + + let bottomSheetViewController = WMFAltTextExperimentModalSheetViewController(viewModel: altTextBottomSheetViewModel, delegate: self, loggingDelegate: self) + + if #available(iOS 16.0, *) { + if let sheet = bottomSheetViewController.sheetPresentationController { + sheet.delegate = self + let customSmallId = UISheetPresentationController.Detent.Identifier("customSmall") + let customSmallDetent = UISheetPresentationController.Detent.custom(identifier: customSmallId) { context in + return 44 + } + sheet.detents = [customSmallDetent, .medium(), .large()] + sheet.selectedDetentIdentifier = .medium + sheet.largestUndimmedDetentIdentifier = .medium + sheet.prefersGrabberVisible = true + } + bottomSheetViewController.isModalInPresentation = true + self.altTextBottomSheetViewController = bottomSheetViewController + + present(bottomSheetViewController, animated: true) { [weak self] in + self?.presentAltTextTooltipsIfNecessary(force: false) + } + } + } + + internal func presentAltTextTooltipsIfNecessary(force: Bool = false) { + guard let altTextExperimentViewModel, + let bottomSheetViewController = altTextBottomSheetViewController, + let tooltip1SourceView = view, + let tooltip2SourceView = bottomSheetViewController.tooltip2SourceView, + let tooltip2SourceRect = bottomSheetViewController.tooltip2SourceRect, + let tooltip3SourceView = bottomSheetViewController.tooltip3SourceView, + let tooltip3SourceRect = bottomSheetViewController.tooltip3SourceRect, + let dataController = WMFAltTextDataController.shared else { + return + } + + if !force && dataController.hasPresentedOnboardingTooltips { + return + } + + let tooltip1SourceRect = CGRect(x: 30, y: navigationBar.frame.height + 30, width: 0, height: 0) + + let viewModel1 = WMFTooltipViewModel(localizedStrings: altTextExperimentViewModel.firstTooltipLocalizedStrings, buttonNeedsDisclosure: true, sourceView: tooltip1SourceView, sourceRect: tooltip1SourceRect, permittedArrowDirections: .up) { [weak self] in + + if let siteURL = self?.articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL) { + EditInteractionFunnel.shared.logAltTextOnboardingDidTapNextOnFirstTooltip(project: project) + } + } + + + let viewModel2 = WMFTooltipViewModel(localizedStrings: altTextExperimentViewModel.secondTooltipLocalizedStrings, buttonNeedsDisclosure: true, sourceView: tooltip2SourceView, sourceRect: tooltip2SourceRect, permittedArrowDirections: .down) + + let viewModel3 = WMFTooltipViewModel(localizedStrings: altTextExperimentViewModel.thirdTooltipLocalizedStrings, buttonNeedsDisclosure: false, sourceView: tooltip3SourceView, sourceRect: tooltip3SourceRect, permittedArrowDirections: .down) { [weak self] in + + if let siteURL = self?.articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL) { + EditInteractionFunnel.shared.logAltTextOnboardingDidTapDoneOnLastTooltip(project: project) + } + + } + + bottomSheetViewController.displayTooltips(tooltipViewModels: [viewModel1, viewModel2, viewModel3]) + + if !force { + dataController.hasPresentedOnboardingTooltips = true + } + } + + internal func loadSummary(oldState: ViewState) { + guard let key = article.inMemoryKey else { + return + } + + var oldFeedPreview: WMFFeedArticlePreview? + if isWidgetCachedFeaturedArticle { + oldFeedPreview = article.feedArticlePreview() + } + + articleLoadWaitGroup?.enter() + let cachePolicy: URLRequest.CachePolicy? = oldState == .reloading ? .reloadRevalidatingCacheData : nil + + self.dataStore.articleSummaryController.updateOrCreateArticleSummaryForArticle(withKey: key, cachePolicy: cachePolicy) { (article, error) in + defer { + self.articleLoadWaitGroup?.leave() + self.updateMenuItems() + } + guard let article = article else { + return + } + self.article = article + + if let oldFeedPreview, + let newFeedPreview = article.feedArticlePreview(), + oldFeedPreview != newFeedPreview { + SharedContainerCacheClearFeaturedArticleWrapper.clearOutFeaturedArticleWidgetCache() + WidgetController.shared.reloadFeaturedArticleWidgetIfNecessary() + } + + // Handle redirects + guard let newKey = article.inMemoryKey, newKey != key, let newURL = article.url else { + return + } + self.articleURL = newURL + self.addToHistory() + } + } + + func loadPage(cachePolicy: WMFCachePolicy? = nil, revisionID: UInt64? = nil) { + defer { + callLoadCompletionIfNecessary() + } + + guard var request = try? fetcher.mobileHTMLRequest(articleURL: articleURL, revisionID: revisionID, scheme: schemeHandler.scheme, cachePolicy: cachePolicy, isPageView: true) else { + showGenericError() + state = .error + return + } + + // Add the URL fragment to request, if the fragment exists + if let articleFragment = URLComponents(url: articleURL, resolvingAgainstBaseURL: true)?.fragment, + let url = request.url, + var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) { + urlComponents.fragment = articleFragment + request.url = urlComponents.url + } + + articleAsLivingDocController.articleContentWillBeginLoading(traitCollection: traitCollection, theme: theme) + + webView.load(request) + } + + func syncCachedResourcesIfNeeded() { + guard let groupKey = articleURL.wmf_databaseKey else { + return + } + + fetcher.isCached(articleURL: articleURL) { [weak self] (isCached) in + + guard let self = self, + isCached else { + return + } + + self.cacheController.syncCachedResources(url: self.articleURL, groupKey: groupKey) { (result) in + switch result { + case .success(let itemKeys): + DDLogDebug("successfully synced \(itemKeys.count) resources") + case .failure(let error): + DDLogError("failed to synced resources for \(groupKey): \(error)") + } + } + } + } +} diff --git a/Wikipedia/Code/ArticleViewController+ArticleLoadErrors.swift b/Wikipedia/Code/ArticleViewController+ArticleLoadErrors.swift new file mode 100644 index 00000000000..c356764ecc8 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+ArticleLoadErrors.swift @@ -0,0 +1,105 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + + +// MARK: - Article Load Errors + +extension ArticleViewController { + func handleArticleLoadFailure(with error: Error, showEmptyView: Bool) { + fakeProgressController.finish() + if showEmptyView { + wmf_showEmptyView(of: .articleDidNotLoad, theme: theme, frame: view.bounds) + } + showError(error) + refreshControl.endRefreshing() + updateRefreshOverlay(visible: false) + } + + func articleLoadDidFail(with error: Error) { + handleArticleLoadFailure(with: error, showEmptyView: !article.isSaved) + } +} + +extension ArticleViewController: WKNavigationDelegate { + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + switch navigationAction.navigationType { + case .reload: + fallthrough + case .other: + setupArticleLoadWaitGroup() + decisionHandler(.allow) + default: + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { + switch navigationAction.navigationType { + case .reload: + fallthrough + case .other: + setupArticleLoadWaitGroup() + decisionHandler(.allow, preferences) + default: + decisionHandler(.cancel, preferences) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + defer { + decisionHandler(.allow) + } + guard let response = navigationResponse.response as? HTTPURLResponse else { + return + } + currentETag = response.allHeaderFields[HTTPURLResponse.etagHeaderKey] as? String + checkForScrollToAnchor(in: response) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + articleLoadDidFail(with: error) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + articleLoadDidFail(with: error) + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + // On process did terminate, the WKWebView goes blank + // Re-load the content in this case to show it again + webView.reload() + } + + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + if shouldPerformWebRefreshAfterScrollViewDeceleration { + updateRefreshOverlay(visible: false) + webView.scrollView.showsVerticalScrollIndicator = true + shouldPerformWebRefreshAfterScrollViewDeceleration = false + } + } +} + +extension ViewController { // Putting extension on ViewController rather than ArticleVC allows for re-use by EditPreviewVC + + var articleMargins: UIEdgeInsets { + return UIEdgeInsets(top: 8, left: articleHorizontalMargin, bottom: 0, right: articleHorizontalMargin) + } + + var articleHorizontalMargin: CGFloat { + let viewForCalculation: UIView = navigationController?.view ?? view + + if let tableOfContentsVC = (self as? ArticleViewController)?.tableOfContentsController.viewController, tableOfContentsVC.isVisible { + // full width + return viewForCalculation.layoutMargins.left + } else { + // If (is EditPreviewVC) or (is TOC OffScreen) then use readableContentGuide to make text inset from screen edges. + // Since readableContentGuide has no effect on compact width, both paths of this `if` statement result in an identical result for smaller screens. + return viewForCalculation.readableContentGuide.layoutFrame.minX + } + } +} + + diff --git a/Wikipedia/Code/ArticleViewController+ArticleStateRestoration.swift b/Wikipedia/Code/ArticleViewController+ArticleStateRestoration.swift new file mode 100644 index 00000000000..595887ad78d --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+ArticleStateRestoration.swift @@ -0,0 +1,67 @@ +//~~~**DELETE THIS HEADER**~~~ + +import Foundation + +// MARK: ArticleViewController+Article State Restoration + +extension ArticleViewController { + /// Save article scroll position for restoration later + func saveArticleScrollPosition() { + getVisibleSection { (sectionId, anchor) in + assert(Thread.isMainThread) + self.article.viewedScrollPosition = Double(self.webView.scrollView.contentOffset.y) + self.article.viewedFragment = anchor + try? self.article.managedObjectContext?.save() + } + } + + /// Perform any necessary initial configuration for state restoration + func setupForStateRestorationIfNecessary() { + guard isRestoringState else { + return + } + setWebViewHidden(true, animated: false) + } + + /// Translates an article's viewedScrollPosition or viewedFragment values to a scrollRestorationState. These values are saved to the article object when the ArticleVC disappears,the app is backgrounded, or an edit is made and the article is reloaded. + func assignScrollStateFromArticleFlagsIfNecessary() { + guard isRestoringState else { + return + } + isRestoringState = false + let scrollPosition = CGFloat(article.viewedScrollPosition) + if scrollPosition > 0 { + scrollRestorationState = .scrollToOffset(scrollPosition, animated: false, completion: { [weak self] success, maxedAttempts in + if success || maxedAttempts { + self?.setWebViewHidden(false, animated: true) + } + }) + } else if let fragment = article.viewedFragment { + scrollRestorationState = .scrollToAnchor(fragment, completion: { [weak self] success, maxedAttempts in + if success || maxedAttempts { + self?.setWebViewHidden(false, animated: true) + } + }) + } else { + setWebViewHidden(false, animated: true) + } + } + + func setWebViewHidden(_ hidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) { + let block = { + self.webView.alpha = hidden ? 0 : 1 + } + guard animated else { + block() + completion?(true) + return + } + UIView.animate(withDuration: 0.3, animations: block, completion: completion) + } + + func callLoadCompletionIfNecessary() { + loadCompletion?() + loadCompletion = nil + } + +} diff --git a/Wikipedia/Code/ArticleViewController+Editing.swift b/Wikipedia/Code/ArticleViewController+Editing.swift index 05c63b3aeee..cff385a1592 100644 --- a/Wikipedia/Code/ArticleViewController+Editing.swift +++ b/Wikipedia/Code/ArticleViewController+Editing.swift @@ -314,7 +314,7 @@ extension ArticleViewController: EditorViewControllerDelegate { private func presentAltTextPromptModal(missingAltTextLink: WMFMissingAltTextLink, filename: String, articleTitle: String, fullArticleWikitext: String, lastRevisionID: UInt64) { guard let siteURL = articleURL.wmf_site, - let languageCode = siteURL.wmf_languageCode, + let _ = siteURL.wmf_languageCode, let project = WikimediaProject(siteURL: siteURL), let wmfProject = project.wmfProject else { return @@ -609,7 +609,7 @@ extension ArticleViewController: WMFAltTextPreviewDelegate { private func presentAltTextPostPublishFeedbackSurvey() { guard let siteURL = articleURL.wmf_site, - let loggedInUser = dataStore.authenticationManager.getLoggedInUserCache(for: siteURL), + let _ = dataStore.authenticationManager.getLoggedInUserCache(for: siteURL), let project = WikimediaProject(siteURL: siteURL) else { return } diff --git a/Wikipedia/Code/ArticleViewController+LeadImage.swift b/Wikipedia/Code/ArticleViewController+LeadImage.swift new file mode 100644 index 00000000000..d7d03f69d50 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+LeadImage.swift @@ -0,0 +1,40 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: - ArticleViewController + LeadImage +extension ArticleViewController { + @objc func userDidTapLeadImage() { + showLeadImage() + } + + func loadLeadImage(with leadImageURL: URL) { + leadImageHeightConstraint.constant = leadImageHeight + leadImageView.wmf_setImage(with: leadImageURL, detectFaces: true, onGPU: true, failure: { (error) in + DDLogWarn("Error loading lead image: \(error)") + }) { + self.updateLeadImageMargins() + self.updateArticleMargins() + + /// see implementation in `extension ArticleViewController: UIContextMenuInteractionDelegate` + let interaction = UIContextMenuInteraction(delegate: self) + self.leadImageView.addInteraction(interaction) + } + } + + override func updateViewConstraints() { + super.updateViewConstraints() + updateLeadImageMargins() + } + + func updateLeadImageMargins() { + let doesArticleUseLargeMargin = (tableOfContentsController.viewController.displayMode == .inline && !tableOfContentsController.viewController.isVisible) + var marginWidth: CGFloat = 0 + if doesArticleUseLargeMargin { + marginWidth = articleHorizontalMargin + } + leadImageLeadingMarginConstraint.constant = marginWidth + leadImageTrailingMarginConstraint.constant = marginWidth + } +} diff --git a/Wikipedia/Code/ArticleViewController+Loading.swift b/Wikipedia/Code/ArticleViewController+Loading.swift new file mode 100644 index 00000000000..0954efb3516 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+Loading.swift @@ -0,0 +1,110 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: - ArticleViewController + Loading +extension ArticleViewController { + override func viewDidLoad() { + setup() + super.viewDidLoad() + + if altTextExperimentViewModel == nil { + setupToolbar() // setup toolbar needs to be after super.viewDidLoad because the superview owns the toolbar + } + + loadWatchStatusAndUpdateToolbar() + setupForStateRestorationIfNecessary() + surveyTimerController?.timerFireBlock = { [weak self] in + guard let self = self, + let result = self.surveyAnnouncementResult else { + return + } + + self.showSurveyAnnouncementPanel(surveyAnnouncementResult: result, linkState: self.articleAsLivingDocController.surveyLinkState) + } + } + + override func viewWillAppear(_ animated: Bool) { + navigationController?.setNavigationBarHidden(true, animated: false) + super.viewWillAppear(animated) + tableOfContentsController.setup(with: traitCollection) + toolbarController.update() + loadIfNecessary() + startSignificantlyViewedTimer() + surveyTimerController?.viewWillAppear(withState: state) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + /// When jumping back to an article via long pressing back button (on iOS 14 or above), W button disappears. Couldn't find cause. It disappears between `viewWillAppear` and `viewDidAppear`, as setting this on the `viewWillAppear`doesn't fix the problem. If we can find source of this bad behavior, we can remove this next line. + + if altTextExperimentViewModel == nil { + setupWButton() + } + + if isReturningFromFAQ { + isReturningFromFAQ = false + needsAltTextExperimentSheet = true + presentAltTextModalSheet() + } + + if didTapPreview { + presentAltTextModalSheet() + didTapPreview = false + } + + if didTapAltTextFileName { + presentAltTextModalSheet() + didTapAltTextFileName = false + } + + if didTapAltTextGalleryInfoButton { + presentAltTextModalSheet() + didTapAltTextGalleryInfoButton = false + } + + guard isFirstAppearance else { + return + } + showAnnouncementIfNeeded() + isFirstAppearance = false + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + tableOfContentsController.update(with: traitCollection) + toolbarController.update() + } + + override func wmf_removePeekableChildViewControllers() { + super.wmf_removePeekableChildViewControllers() + addToHistory() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cancelWIconPopoverDisplay() + saveArticleScrollPosition() + stopSignificantlyViewedTimer() + surveyTimerController?.viewWillDisappear(withState: state) + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if altTextExperimentViewModel != nil { + return .portrait + } + + return super.supportedInterfaceOrientations + } + + override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { + if altTextExperimentViewModel != nil { + return .portrait + } + + return super.preferredInterfaceOrientationForPresentation + } +} + diff --git a/Wikipedia/Code/ArticleViewController+Media.swift b/Wikipedia/Code/ArticleViewController+Media.swift index 037bab1e5df..f77156cf541 100644 --- a/Wikipedia/Code/ArticleViewController+Media.swift +++ b/Wikipedia/Code/ArticleViewController+Media.swift @@ -121,7 +121,7 @@ extension ArticleViewController { } func getGalleryViewController(for item: MediaListItem?, in mediaList: MediaList) -> MediaListGalleryViewController { - let delegate = altTextExperimentViewModel != nil ? self : nil + let _ = altTextExperimentViewModel != nil ? self : nil return MediaListGalleryViewController(articleURL: articleURL, mediaList: mediaList, dataStore: dataStore, initialItem: item, theme: theme, dismissDelegate: self) } } diff --git a/Wikipedia/Code/ArticleViewController+Notifications.swift b/Wikipedia/Code/ArticleViewController+Notifications.swift new file mode 100644 index 00000000000..040c4aea9e3 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+Notifications.swift @@ -0,0 +1,182 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: - ArticleViewController + Notifications + +extension ArticleViewController { + func addNotificationHandlers() { + NotificationCenter.default.addObserver(self, selector: #selector(didReceiveArticleUpdatedNotification), name: NSNotification.Name.WMFArticleUpdated, object: article) + NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + contentSizeObservation = webView.scrollView.observe(\.contentSize) { [weak self] (scrollView, change) in + self?.contentSizeDidChange() + } + } + + /// Track and debounce `contentSize` changes to wait for a desired scroll position to become available. See `ScrollRestorationState` for more information. + func contentSizeDidChange() { + // debounce + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(debouncedContentSizeDidChange), object: nil) + perform(#selector(debouncedContentSizeDidChange), with: nil, afterDelay: 0.1) + } + + @objc func debouncedContentSizeDidChange() { + restoreScrollStateIfNecessary() + } + + @objc func didReceiveArticleUpdatedNotification(_ notification: Notification) { + toolbarController.setSavedState(isSaved: article.isAnyVariantSaved) + } + + @objc func applicationWillResignActive(_ notification: Notification) { + saveArticleScrollPosition() + stopSignificantlyViewedTimer() + surveyTimerController?.willResignActive(withState: state) + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startSignificantlyViewedTimer() + surveyTimerController?.didBecomeActive(withState: state) + } + + func setupSearchButton() { + navigationItem.rightBarButtonItem = AppSearchBarButtonItem.newAppSearchBarButtonItem + } + + func setupMessagingController() { + messagingController.delegate = self + } + + func setupWebView() { + // Add the stack view that contains the table of contents and the web view. + // This stack view is owned by the tableOfContentsController to control presentation of the table of contents + view.wmf_addSubviewWithConstraintsToEdges(tableOfContentsController.stackView) + view.widthAnchor.constraint(equalTo: tableOfContentsController.inlineContainerView.widthAnchor, multiplier: 3).isActive = true + + // Prevent flash of white in dark mode + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + + // Scroll view + scrollView = webView.scrollView // so that content insets are inherited + scrollView?.delegate = self + webView.scrollView.keyboardDismissMode = .interactive + webView.scrollView.refreshControl = refreshControl + + // Lead image + setupLeadImageView() + + // Add overlay to prevent interaction while reloading + webView.wmf_addSubviewWithConstraintsToEdges(refreshOverlay) + + // Delegates + webView.uiDelegate = self + webView.navigationDelegate = self + + // User Agent + webView.customUserAgent = WikipediaAppUtils.versionedUserAgent() + } + + /// Adds the lead image view to the web view's scroll view and configures the associated constraints + func setupLeadImageView() { + webView.scrollView.addSubview(leadImageContainerView) + + let leadingConstraint = leadImageContainerView.leadingAnchor.constraint(equalTo: webView.leadingAnchor) + let trailingConstraint = webView.trailingAnchor.constraint(equalTo: leadImageContainerView.trailingAnchor) + let topConstraint = webView.scrollView.topAnchor.constraint(equalTo: leadImageContainerView.topAnchor) + let imageTopConstraint = leadImageView.topAnchor.constraint(equalTo: leadImageContainerView.topAnchor) + imageTopConstraint.priority = UILayoutPriority(rawValue: 999) + let imageBottomConstraint = leadImageContainerView.bottomAnchor.constraint(equalTo: leadImageView.bottomAnchor, constant: leadImageBorderHeight) + NSLayoutConstraint.activate([topConstraint, leadingConstraint, trailingConstraint, leadImageHeightConstraint, imageTopConstraint, imageBottomConstraint, leadImageLeadingMarginConstraint, leadImageTrailingMarginConstraint]) + + articleAsLivingDocController.setupLeadImageView() + } + + func setupPageContentServiceJavaScriptInterface(with completion: @escaping () -> Void) { + guard let siteURL = articleURL.wmf_site else { + DDLogError("Missing site for \(articleURL)") + showGenericError() + return + } + + // Need user groups to let the Page Content Service know if the page is editable for this user + authManager.getLoggedInUser(for: siteURL) { (result) in + assert(Thread.isMainThread) + switch result { + case .success(let user): + self.setupPageContentServiceJavaScriptInterface(with: user?.groups ?? []) + case .failure: + DDLogError("Error getting userinfo for \(siteURL)") + self.setupPageContentServiceJavaScriptInterface(with: []) + } + completion() + } + } + + func setupPageContentServiceJavaScriptInterface(with userGroups: [String]) { + let areTablesInitiallyExpanded = altTextExperimentViewModel != nil ? true : UserDefaults.standard.wmf_isAutomaticTableOpeningEnabled + + messagingController.shouldAttemptToShowArticleAsLivingDoc = articleAsLivingDocController.shouldAttemptToShowArticleAsLivingDoc + + messagingController.setup(with: webView, languageCode: articleLanguageCode, theme: theme, layoutMargins: articleMargins, leadImageHeight: leadImageHeight, areTablesInitiallyExpanded: areTablesInitiallyExpanded, userGroups: userGroups) + } + + func setupToolbar() { + enableToolbar() + toolbarController.apply(theme: theme) + toolbarController.setSavedState(isSaved: article.isAnyVariantSaved) + setToolbarHidden(false, animated: false) + } + + var isWidgetCachedFeaturedArticle: Bool { + let sharedCache = SharedContainerCache(fileName: SharedContainerCacheCommonNames.widgetCache) + + let cache = sharedCache.loadCache() ?? WidgetCache(settings: .default, featuredContent: nil) + guard let widgetFeaturedArticleURLString = cache.featuredContent?.featuredArticle?.contentURL.desktop.page, + let widgetFeaturedArticleURL = URL(string: widgetFeaturedArticleURLString) else { + return false + } + + return widgetFeaturedArticleURL == articleURL + } + +} + +extension ArticleViewController { + func presentEmbedded(_ viewController: UIViewController, style: WMFThemeableNavigationControllerStyle) { + let nc = WMFThemeableNavigationController(rootViewController: viewController, theme: theme, style: style) + present(nc, animated: true) + } +} + +extension ArticleViewController: ReadingThemesControlsResponding { + func updateWebViewTextSize(textSize: Int) { + messagingController.updateTextSizeAdjustmentPercentage(textSize) + } + + func toggleSyntaxHighlighting(_ controller: ReadingThemesControlsViewController) { + // no-op here, syntax highlighting shouldnt be displayed + } +} + +extension ArticleViewController: ImageScaleTransitionProviding { + var imageScaleTransitionView: UIImageView? { + return leadImageView + } + + func prepareViewsForIncomingImageScaleTransition(with imageView: UIImageView?) { + guard let imageView = imageView, let image = imageView.image else { + return + } + + leadImageHeightConstraint.constant = leadImageHeight + leadImageView.image = image + leadImageView.layer.contentsRect = imageView.layer.contentsRect + + view.layoutIfNeeded() + } + +} diff --git a/Wikipedia/Code/ArticleViewController+Refresh.swift b/Wikipedia/Code/ArticleViewController+Refresh.swift new file mode 100644 index 00000000000..b9c6f2d6618 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+Refresh.swift @@ -0,0 +1,83 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +import Foundation + +// MARK: - ArticleViewController + Refresh + +extension ArticleViewController { + @objc public func refresh() { + state = .reloading + if !shouldPerformWebRefreshAfterScrollViewDeceleration { + updateRefreshOverlay(visible: true) + } + shouldPerformWebRefreshAfterScrollViewDeceleration = true + } + + /// Preserves the current scroll position, loads the provided revisionID or waits for a change in etag on the mobile-html response, then refreshes the page and restores the prior scroll position + internal func waitForNewContentAndRefresh(_ revisionID: UInt64? = nil) { + showNavigationBar() + state = .reloading + saveArticleScrollPosition() + isRestoringState = true + setupForStateRestorationIfNecessary() + // If a revisionID was provided, just load that revision + if let revisionID = revisionID { + performWebViewRefresh(revisionID) + return + } + // If no revisionID was provided, wait for the ETag to change + guard let eTag = currentETag else { + performWebViewRefresh() + return + } + fetcher.waitForMobileHTMLChange(articleURL: articleURL, eTag: eTag, maxAttempts: 5) { (result) in + DispatchQueue.main.async { + switch result { + case .failure(let error): + self.showError(error, sticky: true) + fallthrough + default: + self.performWebViewRefresh() + } + } + } + } + + internal func performWebViewRefresh(_ revisionID: UInt64? = nil) { + + articleAsLivingDocController.articleDidTriggerPullToRefresh() + + switch Configuration.current.environment { + case .local(let options): + if options.contains(.localPCS) { + webView.reloadFromOrigin() + } else { + loadPage(cachePolicy: .noPersistentCacheOnError, revisionID: revisionID) + } + default: + loadPage(cachePolicy: .noPersistentCacheOnError, revisionID: revisionID) + } + } + + internal func updateRefreshOverlay(visible: Bool, animated: Bool = true) { + let duration = animated ? (visible ? 0.15 : 0.1) : 0.0 + let alpha: CGFloat = visible ? 0.3 : 0.0 + UIViewPropertyAnimator.runningPropertyAnimator(withDuration: duration, delay: 0, options: [.curveEaseIn], animations: { + self.refreshOverlay.alpha = alpha + }) + toolbarController.setToolbarButtons(enabled: !visible) + } + + internal func performWebRefreshAfterScrollViewDecelerationIfNeeded() { + guard shouldPerformWebRefreshAfterScrollViewDeceleration else { + return + } + webView.scrollView.showsVerticalScrollIndicator = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { + self.performWebViewRefresh() + }) + } +} diff --git a/Wikipedia/Code/ArticleViewController+ScrollRestorationState.swift b/Wikipedia/Code/ArticleViewController+ScrollRestorationState.swift new file mode 100644 index 00000000000..b6088606aa8 --- /dev/null +++ b/Wikipedia/Code/ArticleViewController+ScrollRestorationState.swift @@ -0,0 +1,64 @@ +import WMFComponents +import WMF +import CocoaLumberjackSwift +import WMFData + +// MARK: - Scroll Restoration State +extension ArticleViewController { + /// Checks scrollRestorationState and performs the necessary scroll restoration + func restoreScrollStateIfNecessary() { + switch scrollRestorationState { + case .none: + break + case .scrollToOffset(let offset, let animated, let attempt, let maxAttempts, let completion): + scrollRestorationState = .none + self.scroll(to: CGPoint(x: 0, y: offset), animated: animated) { [weak self] (success) in + guard !success, attempt < maxAttempts else { + completion?(success, attempt >= maxAttempts) + return + } + self?.scrollRestorationState = .scrollToOffset(offset, animated: animated, attempt: attempt + 1, maxAttempts: maxAttempts, completion: completion) + } + case .scrollToPercentage(let verticalOffsetPercentage): + scrollRestorationState = .none + webView.scrollView.verticalOffsetPercentage = verticalOffsetPercentage + case .scrollToAnchor(let anchor, let attempt, let maxAttempts, let completion): + scrollRestorationState = .none + self.scroll(to: anchor, animated: true) { [weak self] (success) in + guard !success, attempt < maxAttempts else { + completion?(success, attempt >= maxAttempts) + return + } + self?.scrollRestorationState = .scrollToAnchor(anchor, attempt: attempt + 1, maxAttempts: maxAttempts, completion: completion) + } + + // HACK: Sometimes the `scroll_to_anchor` message is not triggered from the web view over the JS bridge, even after prepareForScrollToAnchor successfully goes through. This means the completion block above is queued to scrollToAnchorCompletions but never run. We are trying to scroll again here once more after a slight delay in hopes of triggering `scroll_to_anchor` again. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { [weak self] in + + guard let self = self else { + return + } + + // This conditional check should target the bug a little closer, since scrollToAnchorCompletions are cleaned out after the last `scroll_to_anchor` message is received. Remaining scrollToAnchorCompletions at this point indicates that likely we're hitting the missing `scroll_to_anchor` message bug. + if self.scrollToAnchorCompletions.count > 0 { + self.scroll(to: anchor, animated: false) + } + } + } + } + + internal func stashOffsetPercentage() { + let offset = webView.scrollView.verticalOffsetPercentage + // negative and 0 offsets make small errors in scrolling, allow it to automatically handle those cases + if offset > 0 { + scrollRestorationState = .scrollToPercentage(offset) + } + } + + internal func checkForScrollToAnchor(in response: HTTPURLResponse) { + guard let fragment = response.url?.fragment else { + return + } + scrollRestorationState = .scrollToAnchor(fragment, attempt: 1) + } +} diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index ce9c06085ef..3610876010d 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -45,8 +45,7 @@ class ArticleViewController: ViewController, HintPresenting { internal let schemeHandler: SchemeHandler internal let dataStore: MWKDataStore - - private let cacheController: ArticleCacheController + internal let cacheController: ArticleCacheController var session: Session { return dataStore.session @@ -70,15 +69,14 @@ class ArticleViewController: ViewController, HintPresenting { internal lazy var fetcher: ArticleFetcher = ArticleFetcher(session: session, configuration: configuration) - private var leadImageHeight: CGFloat = 210 - - private var contentSizeObservation: NSKeyValueObservation? = nil + internal var leadImageHeight: CGFloat = 210 + internal var contentSizeObservation: NSKeyValueObservation? = nil /// Current ETag of the web content response. Used to verify when content has changed on the server. var currentETag: String? /// Used to delay reloading the web view to prevent `UIScrollView` jitter - fileprivate var shouldPerformWebRefreshAfterScrollViewDeceleration = false + internal var shouldPerformWebRefreshAfterScrollViewDeceleration = false lazy var refreshControl: UIRefreshControl = { let rc = UIRefreshControl() @@ -109,8 +107,8 @@ class ArticleViewController: ViewController, HintPresenting { private(set) var altTextBottomSheetViewModel: WMFAltTextExperimentModalSheetViewModel? private(set) var altTextExperimentViewModel: WMFAltTextExperimentViewModel? private(set) weak var altTextDelegate: AltTextDelegate? - private var needsAltTextExperimentSheet: Bool = false - private var isReturningFromFAQ = false + internal var needsAltTextExperimentSheet: Bool = false + internal var isReturningFromFAQ = false var altTextExperimentAcceptDate: Date? var wasPresentingGalleryWhileInAltTextMode = false var didTapPreview: Bool = false /// Set when coming back from alt text preview @@ -118,7 +116,7 @@ class ArticleViewController: ViewController, HintPresenting { var didTapAltTextGalleryInfoButton = false var altTextArticleEditorOnboardingPresenter: AltTextArticleEditorOnboardingPresenter? var altTextGuidancePresenter: AltTextGuidancePresenter? - private weak var altTextBottomSheetViewController: WMFAltTextExperimentModalSheetViewController? + internal weak var altTextBottomSheetViewController: WMFAltTextExperimentModalSheetViewController? convenience init?(articleURL: URL, dataStore: MWKDataStore, theme: Theme, schemeHandler: SchemeHandler? = nil, altTextExperimentViewModel: WMFAltTextExperimentViewModel, needsAltTextExperimentSheet: Bool, altTextBottomSheetViewModel: WMFAltTextExperimentModalSheetViewModel?, altTextDelegate: AltTextDelegate?) { self.init(articleURL: articleURL, dataStore: dataStore, theme: theme) @@ -198,26 +196,37 @@ class ArticleViewController: ViewController, HintPresenting { return findInPage.view } - // MARK: Lead Image + // Mark: Loading - @objc func userDidTapLeadImage() { - showLeadImage() + var state: ViewState = .initial { + didSet { + switch state { + case .initial: + break + case .reloading: + fallthrough + case .loading: + fakeProgressController.start() + case .loaded: + fakeProgressController.stop() + rethemeWebViewIfNecessary() + case .error: + fakeProgressController.stop() + } + } } - func loadLeadImage(with leadImageURL: URL) { - leadImageHeightConstraint.constant = leadImageHeight - leadImageView.wmf_setImage(with: leadImageURL, detectFaces: true, onGPU: true, failure: { (error) in - DDLogWarn("Error loading lead image: \(error)") - }) { - self.updateLeadImageMargins() - self.updateArticleMargins() - - /// see implementation in `extension ArticleViewController: UIContextMenuInteractionDelegate` - let interaction = UIContextMenuInteraction(delegate: self) - self.leadImageView.addInteraction(interaction) - } + lazy internal var fakeProgressController: FakeProgressController = { + let progressController = FakeProgressController(progress: navigationBar, delegate: navigationBar) + progressController.delay = 0.0 + return progressController + }() + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } + // MARK: Lead image lazy var leadImageLeadingMarginConstraint: NSLayoutConstraint = { return leadImageView.leadingAnchor.constraint(equalTo: leadImageContainerView.leadingAnchor) }() @@ -273,20 +282,6 @@ class ArticleViewController: ViewController, HintPresenting { return view }() - override func updateViewConstraints() { - super.updateViewConstraints() - updateLeadImageMargins() - } - - func updateLeadImageMargins() { - let doesArticleUseLargeMargin = (tableOfContentsController.viewController.displayMode == .inline && !tableOfContentsController.viewController.isVisible) - var marginWidth: CGFloat = 0 - if doesArticleUseLargeMargin { - marginWidth = articleHorizontalMargin - } - leadImageLeadingMarginConstraint.constant = marginWidth - leadImageTrailingMarginConstraint.constant = marginWidth - } // MARK: Previewing @@ -336,368 +331,10 @@ class ArticleViewController: ViewController, HintPresenting { } } - // MARK: Loading - - var state: ViewState = .initial { - didSet { - switch state { - case .initial: - break - case .reloading: - fallthrough - case .loading: - fakeProgressController.start() - case .loaded: - fakeProgressController.stop() - rethemeWebViewIfNecessary() - case .error: - fakeProgressController.stop() - } - } - } - - lazy private var fakeProgressController: FakeProgressController = { - let progressController = FakeProgressController(progress: navigationBar, delegate: navigationBar) - progressController.delay = 0.0 - return progressController - }() - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - setup() - super.viewDidLoad() - - if altTextExperimentViewModel == nil { - setupToolbar() // setup toolbar needs to be after super.viewDidLoad because the superview owns the toolbar - } - - loadWatchStatusAndUpdateToolbar() - setupForStateRestorationIfNecessary() - surveyTimerController?.timerFireBlock = { [weak self] in - guard let self = self, - let result = self.surveyAnnouncementResult else { - return - } - - self.showSurveyAnnouncementPanel(surveyAnnouncementResult: result, linkState: self.articleAsLivingDocController.surveyLinkState) - } - } - - override func viewWillAppear(_ animated: Bool) { - navigationController?.setNavigationBarHidden(true, animated: false) - super.viewWillAppear(animated) - tableOfContentsController.setup(with: traitCollection) - toolbarController.update() - loadIfNecessary() - startSignificantlyViewedTimer() - surveyTimerController?.viewWillAppear(withState: state) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - /// When jumping back to an article via long pressing back button (on iOS 14 or above), W button disappears. Couldn't find cause. It disappears between `viewWillAppear` and `viewDidAppear`, as setting this on the `viewWillAppear`doesn't fix the problem. If we can find source of this bad behavior, we can remove this next line. - - if altTextExperimentViewModel == nil { - setupWButton() - } - - if isReturningFromFAQ { - isReturningFromFAQ = false - needsAltTextExperimentSheet = true - presentAltTextModalSheet() - } - - if didTapPreview { - presentAltTextModalSheet() - didTapPreview = false - } - - if didTapAltTextFileName { - presentAltTextModalSheet() - didTapAltTextFileName = false - } - - if didTapAltTextGalleryInfoButton { - presentAltTextModalSheet() - didTapAltTextGalleryInfoButton = false - } - - guard isFirstAppearance else { - return - } - showAnnouncementIfNeeded() - isFirstAppearance = false - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - tableOfContentsController.update(with: traitCollection) - toolbarController.update() - } - - override func wmf_removePeekableChildViewControllers() { - super.wmf_removePeekableChildViewControllers() - addToHistory() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - cancelWIconPopoverDisplay() - saveArticleScrollPosition() - stopSignificantlyViewedTimer() - surveyTimerController?.viewWillDisappear(withState: state) - } - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - if altTextExperimentViewModel != nil { - return .portrait - } - - return super.supportedInterfaceOrientations - } - - override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { - if altTextExperimentViewModel != nil { - return .portrait - } - - return super.preferredInterfaceOrientationForPresentation - } - // MARK: Article load var articleLoadWaitGroup: DispatchGroup? - func loadIfNecessary() { - guard state == .initial else { - return - } - load() - } - - func load() { - state = .loading - - setupPageContentServiceJavaScriptInterface { - let cachePolicy: WMFCachePolicy? = self.isRestoringState ? .foundation(.returnCacheDataElseLoad) : nil - - let revisionID = self.altTextExperimentViewModel != nil ? self.altTextExperimentViewModel?.lastRevisionID : nil - - self.loadPage(cachePolicy: cachePolicy, revisionID: revisionID) - } - } - - /// Waits for the article and article summary to finish loading (or re-loading) and performs post load actions - private func setupArticleLoadWaitGroup() { - assert(Thread.isMainThread) - - guard articleLoadWaitGroup == nil else { - return - } - - articleLoadWaitGroup = DispatchGroup() - articleLoadWaitGroup?.enter() // will leave on setup complete - articleLoadWaitGroup?.notify(queue: DispatchQueue.main) { [weak self] in - - guard let self = self else { - return - } - - self.articleAsLivingDocController.articleContentFinishedLoading() - - if altTextExperimentViewModel != nil { - self.setupForAltTextExperiment() - } else { - self.setupFooter() - } - - self.shareIfNecessary() - self.restoreScrollStateIfNecessary() - self.articleLoadWaitGroup = nil - } - } - - private func setupForAltTextExperiment() { - - guard let altTextExperimentViewModel, - altTextBottomSheetViewModel != nil else { - return - } - - let oldContentInset = webView.scrollView.contentInset - webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: view.bounds.height * 0.65, right: oldContentInset.right) - messagingController.hideEditPencils() - messagingController.scrollToNewImage(filename: altTextExperimentViewModel.filename) - - presentAltTextModalSheet() - } - - func presentAltTextModalSheet() { - - guard altTextExperimentViewModel != nil, - let altTextBottomSheetViewModel else { - return - } - - let bottomSheetViewController = WMFAltTextExperimentModalSheetViewController(viewModel: altTextBottomSheetViewModel, delegate: self, loggingDelegate: self) - - if #available(iOS 16.0, *) { - if let sheet = bottomSheetViewController.sheetPresentationController { - sheet.delegate = self - let customSmallId = UISheetPresentationController.Detent.Identifier("customSmall") - let customSmallDetent = UISheetPresentationController.Detent.custom(identifier: customSmallId) { context in - return 44 - } - sheet.detents = [customSmallDetent, .medium(), .large()] - sheet.selectedDetentIdentifier = .medium - sheet.largestUndimmedDetentIdentifier = .medium - sheet.prefersGrabberVisible = true - } - bottomSheetViewController.isModalInPresentation = true - self.altTextBottomSheetViewController = bottomSheetViewController - - present(bottomSheetViewController, animated: true) { [weak self] in - self?.presentAltTextTooltipsIfNecessary(force: false) - } - } - } - - private func presentAltTextTooltipsIfNecessary(force: Bool = false) { - - guard let altTextExperimentViewModel, - let bottomSheetViewController = altTextBottomSheetViewController, - let tooltip1SourceView = view, - let tooltip2SourceView = bottomSheetViewController.tooltip2SourceView, - let tooltip2SourceRect = bottomSheetViewController.tooltip2SourceRect, - let tooltip3SourceView = bottomSheetViewController.tooltip3SourceView, - let tooltip3SourceRect = bottomSheetViewController.tooltip3SourceRect, - let dataController = WMFAltTextDataController.shared else { - return - } - - if !force && dataController.hasPresentedOnboardingTooltips { - return - } - - let tooltip1SourceRect = CGRect(x: 30, y: navigationBar.frame.height + 30, width: 0, height: 0) - - let viewModel1 = WMFTooltipViewModel(localizedStrings: altTextExperimentViewModel.firstTooltipLocalizedStrings, buttonNeedsDisclosure: true, sourceView: tooltip1SourceView, sourceRect: tooltip1SourceRect, permittedArrowDirections: .up) { [weak self] in - - if let siteURL = self?.articleURL.wmf_site, - let project = WikimediaProject(siteURL: siteURL) { - EditInteractionFunnel.shared.logAltTextOnboardingDidTapNextOnFirstTooltip(project: project) - } - } - - - let viewModel2 = WMFTooltipViewModel(localizedStrings: altTextExperimentViewModel.secondTooltipLocalizedStrings, buttonNeedsDisclosure: true, sourceView: tooltip2SourceView, sourceRect: tooltip2SourceRect, permittedArrowDirections: .down) - - let viewModel3 = WMFTooltipViewModel(localizedStrings: altTextExperimentViewModel.thirdTooltipLocalizedStrings, buttonNeedsDisclosure: false, sourceView: tooltip3SourceView, sourceRect: tooltip3SourceRect, permittedArrowDirections: .down) { [weak self] in - - if let siteURL = self?.articleURL.wmf_site, - let project = WikimediaProject(siteURL: siteURL) { - EditInteractionFunnel.shared.logAltTextOnboardingDidTapDoneOnLastTooltip(project: project) - } - - } - - bottomSheetViewController.displayTooltips(tooltipViewModels: [viewModel1, viewModel2, viewModel3]) - - if !force { - dataController.hasPresentedOnboardingTooltips = true - } - } - - internal func loadSummary(oldState: ViewState) { - guard let key = article.inMemoryKey else { - return - } - - var oldFeedPreview: WMFFeedArticlePreview? - if isWidgetCachedFeaturedArticle { - oldFeedPreview = article.feedArticlePreview() - } - - articleLoadWaitGroup?.enter() - let cachePolicy: URLRequest.CachePolicy? = oldState == .reloading ? .reloadRevalidatingCacheData : nil - - self.dataStore.articleSummaryController.updateOrCreateArticleSummaryForArticle(withKey: key, cachePolicy: cachePolicy) { (article, error) in - defer { - self.articleLoadWaitGroup?.leave() - self.updateMenuItems() - } - guard let article = article else { - return - } - self.article = article - - if let oldFeedPreview, - let newFeedPreview = article.feedArticlePreview(), - oldFeedPreview != newFeedPreview { - SharedContainerCacheClearFeaturedArticleWrapper.clearOutFeaturedArticleWidgetCache() - WidgetController.shared.reloadFeaturedArticleWidgetIfNecessary() - } - - // Handle redirects - guard let newKey = article.inMemoryKey, newKey != key, let newURL = article.url else { - return - } - self.articleURL = newURL - self.addToHistory() - } - } - - func loadPage(cachePolicy: WMFCachePolicy? = nil, revisionID: UInt64? = nil) { - defer { - callLoadCompletionIfNecessary() - } - - guard var request = try? fetcher.mobileHTMLRequest(articleURL: articleURL, revisionID: revisionID, scheme: schemeHandler.scheme, cachePolicy: cachePolicy, isPageView: true) else { - showGenericError() - state = .error - return - } - - // Add the URL fragment to request, if the fragment exists - if let articleFragment = URLComponents(url: articleURL, resolvingAgainstBaseURL: true)?.fragment, - let url = request.url, - var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) { - urlComponents.fragment = articleFragment - request.url = urlComponents.url - } - - articleAsLivingDocController.articleContentWillBeginLoading(traitCollection: traitCollection, theme: theme) - - webView.load(request) - } - - func syncCachedResourcesIfNeeded() { - guard let groupKey = articleURL.wmf_databaseKey else { - return - } - - fetcher.isCached(articleURL: articleURL) { [weak self] (isCached) in - - guard let self = self, - isCached else { - return - } - - self.cacheController.syncCachedResources(url: self.articleURL, groupKey: groupKey) { (result) in - switch result { - case .success(let itemKeys): - DDLogDebug("successfully synced \(itemKeys.count) resources") - case .failure(let error): - DDLogError("failed to synced resources for \(groupKey): \(error)") - } - } - } - } - // MARK: History func addToHistory() { @@ -731,7 +368,7 @@ class ArticleViewController: ViewController, HintPresenting { /// This occurs when a user is re-opening the app and expects the article to be scrolled to the last position they were reading at or when a user taps on a link that goes to a particular section in another article. /// The state needs to be preserved because the given offset or anchor will not be availble until after the page fully loads. /// `scrollToOffset` and `scrollToAnchor` will track attempts made after each `webView.contentSize` change, hoping the requested offset or anchor is available. After a certain number of attempts, it's assumed that the value is invalid and the restoration logic gives up. - private enum ScrollRestorationState { + internal enum ScrollRestorationState { case none /// Scroll to absolute Y offset case scrollToOffset(_ offsetY: CGFloat, animated: Bool, attempt: Int = 1, maxAttempts: Int = 5, completion: ((Bool, Bool) -> Void)? = nil) @@ -741,125 +378,7 @@ class ArticleViewController: ViewController, HintPresenting { case scrollToAnchor(_ anchor: String, attempt: Int = 1, maxAttempts: Int = 5, completion: ((Bool, Bool) -> Void)? = nil) } - private var scrollRestorationState: ScrollRestorationState = .none - - /// Checks scrollRestorationState and performs the necessary scroll restoration - private func restoreScrollStateIfNecessary() { - switch scrollRestorationState { - case .none: - break - case .scrollToOffset(let offset, let animated, let attempt, let maxAttempts, let completion): - scrollRestorationState = .none - self.scroll(to: CGPoint(x: 0, y: offset), animated: animated) { [weak self] (success) in - guard !success, attempt < maxAttempts else { - completion?(success, attempt >= maxAttempts) - return - } - self?.scrollRestorationState = .scrollToOffset(offset, animated: animated, attempt: attempt + 1, maxAttempts: maxAttempts, completion: completion) - } - case .scrollToPercentage(let verticalOffsetPercentage): - scrollRestorationState = .none - webView.scrollView.verticalOffsetPercentage = verticalOffsetPercentage - case .scrollToAnchor(let anchor, let attempt, let maxAttempts, let completion): - scrollRestorationState = .none - self.scroll(to: anchor, animated: true) { [weak self] (success) in - guard !success, attempt < maxAttempts else { - completion?(success, attempt >= maxAttempts) - return - } - self?.scrollRestorationState = .scrollToAnchor(anchor, attempt: attempt + 1, maxAttempts: maxAttempts, completion: completion) - } - - // HACK: Sometimes the `scroll_to_anchor` message is not triggered from the web view over the JS bridge, even after prepareForScrollToAnchor successfully goes through. This means the completion block above is queued to scrollToAnchorCompletions but never run. We are trying to scroll again here once more after a slight delay in hopes of triggering `scroll_to_anchor` again. - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { [weak self] in - - guard let self = self else { - return - } - - // This conditional check should target the bug a little closer, since scrollToAnchorCompletions are cleaned out after the last `scroll_to_anchor` message is received. Remaining scrollToAnchorCompletions at this point indicates that likely we're hitting the missing `scroll_to_anchor` message bug. - if self.scrollToAnchorCompletions.count > 0 { - self.scroll(to: anchor, animated: false) - } - } - } - } - - internal func stashOffsetPercentage() { - let offset = webView.scrollView.verticalOffsetPercentage - // negative and 0 offsets make small errors in scrolling, allow it to automatically handle those cases - if offset > 0 { - scrollRestorationState = .scrollToPercentage(offset) - } - } - - private func checkForScrollToAnchor(in response: HTTPURLResponse) { - guard let fragment = response.url?.fragment else { - return - } - scrollRestorationState = .scrollToAnchor(fragment, attempt: 1) - } - - // MARK: Article State Restoration - - /// Save article scroll position for restoration later - func saveArticleScrollPosition() { - getVisibleSection { (sectionId, anchor) in - assert(Thread.isMainThread) - self.article.viewedScrollPosition = Double(self.webView.scrollView.contentOffset.y) - self.article.viewedFragment = anchor - try? self.article.managedObjectContext?.save() - } - } - - /// Perform any necessary initial configuration for state restoration - func setupForStateRestorationIfNecessary() { - guard isRestoringState else { - return - } - setWebViewHidden(true, animated: false) - } - - /// Translates an article's viewedScrollPosition or viewedFragment values to a scrollRestorationState. These values are saved to the article object when the ArticleVC disappears,the app is backgrounded, or an edit is made and the article is reloaded. - func assignScrollStateFromArticleFlagsIfNecessary() { - guard isRestoringState else { - return - } - isRestoringState = false - let scrollPosition = CGFloat(article.viewedScrollPosition) - if scrollPosition > 0 { - scrollRestorationState = .scrollToOffset(scrollPosition, animated: false, completion: { [weak self] success, maxedAttempts in - if success || maxedAttempts { - self?.setWebViewHidden(false, animated: true) - } - }) - } else if let fragment = article.viewedFragment { - scrollRestorationState = .scrollToAnchor(fragment, completion: { [weak self] success, maxedAttempts in - if success || maxedAttempts { - self?.setWebViewHidden(false, animated: true) - } - }) - } else { - setWebViewHidden(false, animated: true) - } - } - - func setWebViewHidden(_ hidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) { - let block = { - self.webView.alpha = hidden ? 0 : 1 - } - guard animated else { - block() - completion?(true) - return - } - UIView.animate(withDuration: 0.3, animations: block, completion: completion) - } - - func callLoadCompletionIfNecessary() { - loadCompletion?() - loadCompletion = nil - } + internal var scrollRestorationState: ScrollRestorationState = .none // MARK: Theme @@ -921,81 +440,6 @@ class ArticleViewController: ViewController, HintPresenting { scroll(to: anchor, animated: true) } - // MARK: Refresh - - @objc public func refresh() { - state = .reloading - if !shouldPerformWebRefreshAfterScrollViewDeceleration { - updateRefreshOverlay(visible: true) - } - shouldPerformWebRefreshAfterScrollViewDeceleration = true - } - - /// Preserves the current scroll position, loads the provided revisionID or waits for a change in etag on the mobile-html response, then refreshes the page and restores the prior scroll position - internal func waitForNewContentAndRefresh(_ revisionID: UInt64? = nil) { - showNavigationBar() - state = .reloading - saveArticleScrollPosition() - isRestoringState = true - setupForStateRestorationIfNecessary() - // If a revisionID was provided, just load that revision - if let revisionID = revisionID { - performWebViewRefresh(revisionID) - return - } - // If no revisionID was provided, wait for the ETag to change - guard let eTag = currentETag else { - performWebViewRefresh() - return - } - fetcher.waitForMobileHTMLChange(articleURL: articleURL, eTag: eTag, maxAttempts: 5) { (result) in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.showError(error, sticky: true) - fallthrough - default: - self.performWebViewRefresh() - } - } - } - } - - internal func performWebViewRefresh(_ revisionID: UInt64? = nil) { - - articleAsLivingDocController.articleDidTriggerPullToRefresh() - - switch Configuration.current.environment { - case .local(let options): - if options.contains(.localPCS) { - webView.reloadFromOrigin() - } else { - loadPage(cachePolicy: .noPersistentCacheOnError, revisionID: revisionID) - } - default: - loadPage(cachePolicy: .noPersistentCacheOnError, revisionID: revisionID) - } - } - - internal func updateRefreshOverlay(visible: Bool, animated: Bool = true) { - let duration = animated ? (visible ? 0.15 : 0.1) : 0.0 - let alpha: CGFloat = visible ? 0.3 : 0.0 - UIViewPropertyAnimator.runningPropertyAnimator(withDuration: duration, delay: 0, options: [.curveEaseIn], animations: { - self.refreshOverlay.alpha = alpha - }) - toolbarController.setToolbarButtons(enabled: !visible) - } - - internal func performWebRefreshAfterScrollViewDecelerationIfNeeded() { - guard shouldPerformWebRefreshAfterScrollViewDeceleration else { - return - } - webView.scrollView.showsVerticalScrollIndicator = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { - self.performWebViewRefresh() - }) - } - // MARK: Overrideable functionality internal func handleLink(with href: String) { @@ -1109,487 +553,7 @@ class ArticleViewController: ViewController, HintPresenting { performWebRefreshAfterScrollViewDecelerationIfNeeded() } } - - // MARK: Analytics - - internal lazy var readingListsFunnel = ReadingListsFunnel.shared -} - -private extension ArticleViewController { - - func setup() { - if let altTextExperimentViewModel { - self.navigationItem.titleView = nil - self.title = altTextExperimentViewModel.localizedStrings.articleNavigationBarTitle - - let rightBarButtonItem = - UIBarButtonItem( - image: WMFSFSymbolIcon.for(symbol: .ellipsisCircle), - primaryAction: nil, - menu: overflowMenu - ) - navigationItem.rightBarButtonItem = rightBarButtonItem - rightBarButtonItem.tintColor = theme.colors.link - - self.navigationBar.updateNavigationItems() - } else { - setupWButton() - setupSearchButton() - } - - addNotificationHandlers() - setupWebView() - setupMessagingController() - } - - private var overflowMenu: UIMenu { - let learnMore = UIAction(title: CommonStrings.learnMoreTitle(), image: WMFSFSymbolIcon.for(symbol: .infoCircle), handler: { [weak self] _ in - if let project = self?.project { - EditInteractionFunnel.shared.logAltTextEditingInterfaceOverflowLearnMore(project: project) - } - self?.goToFAQ() - }) - - let tutorial = UIAction(title: CommonStrings.tutorialTitle, image: WMFSFSymbolIcon.for(symbol: .lightbulbMin), handler: { [weak self] _ in - if let project = self?.project { - EditInteractionFunnel.shared.logAltTextEditingInterfaceOverflowTutorial(project: project) - } - self?.showTutorial() - }) - - let reportIssues = UIAction(title: CommonStrings.problemWithFeatureTitle, image: WMFSFSymbolIcon.for(symbol: .flag), handler: { [weak self] _ in - if let project = self?.project { - EditInteractionFunnel.shared.logAltTextEditingInterfaceOverflowReport(project: project) - } - self?.reportIssue() - }) - - let menuItems: [UIMenuElement] = [learnMore, tutorial, reportIssues] - - return UIMenu(title: String(), children: menuItems) - } - - private func goToFAQ() { - if let altTextExperimentViewModel { - isReturningFromFAQ = true - navigate(to: altTextExperimentViewModel.learnMoreURL, useSafari: false) - } - } - - private func showTutorial() { - presentAltTextTooltipsIfNecessary(force: true) - } - - private func reportIssue() { - let emailAddress = "ios-support@wikimedia.org" - let emailSubject = WMFLocalizedString("alt-text-email-title", value: "Issue Report - Alt Text Feature", comment: "Title text for Alt Text pre-filled issue report email") - let emailBodyLine1 = WMFLocalizedString("alt-text-email-first-line", value: "I've encountered a problem with the Alt Text feature:", comment: "Text for Alt Text pre-filled issue report email") - let emailBodyLine2 = WMFLocalizedString("alt-text-email-second-line", value: "- [Describe specific problem]", comment: "Text for Alt Text pre-filled issue report email. This text is intended to be replaced by the user with a description of the problem they are encountering") - let emailBodyLine3 = WMFLocalizedString("alt-text-email-third-line", value: "The behavior I would like to see is:", comment: "Text for Alt Text pre-filled issue report email") - let emailBodyLine4 = WMFLocalizedString("alt-text-email-fourth-line", value: "- [Describe proposed solution]", comment: "Text for Alt Text pre-filled issue report email. This text is intended to be replaced by the user with a description of a user suggested solution") - let emailBodyLine5 = WMFLocalizedString("alt-text-email-fifth-line", value: "[Screenshots or Links]", comment: "Text for Alt Text pre-filled issue report email. This text is intended to be replaced by the user with a screenshot or link.") - let emailBody = "\(emailBodyLine1)\n\n\(emailBodyLine2)\n\n\(emailBodyLine3)\n\n\(emailBodyLine4)\n\n\(emailBodyLine5)" - let mailto = "mailto:\(emailAddress)?subject=\(emailSubject)&body=\(emailBody)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - - guard let encodedMailto = mailto, let mailtoURL = URL(string: encodedMailto), UIApplication.shared.canOpenURL(mailtoURL) else { - WMFAlertManager.sharedInstance.showErrorAlertWithMessage(CommonStrings.noEmailClient, sticky: false, dismissPreviousAlerts: false) - return - } - UIApplication.shared.open(mailtoURL) - } // MARK: Notifications - - func addNotificationHandlers() { - NotificationCenter.default.addObserver(self, selector: #selector(didReceiveArticleUpdatedNotification), name: NSNotification.Name.WMFArticleUpdated, object: article) - NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) - contentSizeObservation = webView.scrollView.observe(\.contentSize) { [weak self] (scrollView, change) in - self?.contentSizeDidChange() - } - } - - /// Track and debounce `contentSize` changes to wait for a desired scroll position to become available. See `ScrollRestorationState` for more information. - func contentSizeDidChange() { - // debounce - NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(debouncedContentSizeDidChange), object: nil) - perform(#selector(debouncedContentSizeDidChange), with: nil, afterDelay: 0.1) - } - - @objc func debouncedContentSizeDidChange() { - restoreScrollStateIfNecessary() - } - - @objc func didReceiveArticleUpdatedNotification(_ notification: Notification) { - toolbarController.setSavedState(isSaved: article.isAnyVariantSaved) - } - - @objc func applicationWillResignActive(_ notification: Notification) { - saveArticleScrollPosition() - stopSignificantlyViewedTimer() - surveyTimerController?.willResignActive(withState: state) - } - - @objc func applicationDidBecomeActive(_ notification: Notification) { - startSignificantlyViewedTimer() - surveyTimerController?.didBecomeActive(withState: state) - } - - func setupSearchButton() { - navigationItem.rightBarButtonItem = AppSearchBarButtonItem.newAppSearchBarButtonItem - } - - func setupMessagingController() { - messagingController.delegate = self - } - - func setupWebView() { - // Add the stack view that contains the table of contents and the web view. - // This stack view is owned by the tableOfContentsController to control presentation of the table of contents - view.wmf_addSubviewWithConstraintsToEdges(tableOfContentsController.stackView) - view.widthAnchor.constraint(equalTo: tableOfContentsController.inlineContainerView.widthAnchor, multiplier: 3).isActive = true - - // Prevent flash of white in dark mode - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = .clear - - // Scroll view - scrollView = webView.scrollView // so that content insets are inherited - scrollView?.delegate = self - webView.scrollView.keyboardDismissMode = .interactive - webView.scrollView.refreshControl = refreshControl - - // Lead image - setupLeadImageView() - - // Add overlay to prevent interaction while reloading - webView.wmf_addSubviewWithConstraintsToEdges(refreshOverlay) - - // Delegates - webView.uiDelegate = self - webView.navigationDelegate = self - - // User Agent - webView.customUserAgent = WikipediaAppUtils.versionedUserAgent() - } - - /// Adds the lead image view to the web view's scroll view and configures the associated constraints - func setupLeadImageView() { - webView.scrollView.addSubview(leadImageContainerView) - - let leadingConstraint = leadImageContainerView.leadingAnchor.constraint(equalTo: webView.leadingAnchor) - let trailingConstraint = webView.trailingAnchor.constraint(equalTo: leadImageContainerView.trailingAnchor) - let topConstraint = webView.scrollView.topAnchor.constraint(equalTo: leadImageContainerView.topAnchor) - let imageTopConstraint = leadImageView.topAnchor.constraint(equalTo: leadImageContainerView.topAnchor) - imageTopConstraint.priority = UILayoutPriority(rawValue: 999) - let imageBottomConstraint = leadImageContainerView.bottomAnchor.constraint(equalTo: leadImageView.bottomAnchor, constant: leadImageBorderHeight) - NSLayoutConstraint.activate([topConstraint, leadingConstraint, trailingConstraint, leadImageHeightConstraint, imageTopConstraint, imageBottomConstraint, leadImageLeadingMarginConstraint, leadImageTrailingMarginConstraint]) - - articleAsLivingDocController.setupLeadImageView() - } - - func setupPageContentServiceJavaScriptInterface(with completion: @escaping () -> Void) { - guard let siteURL = articleURL.wmf_site else { - DDLogError("Missing site for \(articleURL)") - showGenericError() - return - } - - // Need user groups to let the Page Content Service know if the page is editable for this user - authManager.getLoggedInUser(for: siteURL) { (result) in - assert(Thread.isMainThread) - switch result { - case .success(let user): - self.setupPageContentServiceJavaScriptInterface(with: user?.groups ?? []) - case .failure: - DDLogError("Error getting userinfo for \(siteURL)") - self.setupPageContentServiceJavaScriptInterface(with: []) - } - completion() - } - } - - func setupPageContentServiceJavaScriptInterface(with userGroups: [String]) { - let areTablesInitiallyExpanded = altTextExperimentViewModel != nil ? true : UserDefaults.standard.wmf_isAutomaticTableOpeningEnabled - - messagingController.shouldAttemptToShowArticleAsLivingDoc = articleAsLivingDocController.shouldAttemptToShowArticleAsLivingDoc - - messagingController.setup(with: webView, languageCode: articleLanguageCode, theme: theme, layoutMargins: articleMargins, leadImageHeight: leadImageHeight, areTablesInitiallyExpanded: areTablesInitiallyExpanded, userGroups: userGroups) - } - - func setupToolbar() { - enableToolbar() - toolbarController.apply(theme: theme) - toolbarController.setSavedState(isSaved: article.isAnyVariantSaved) - setToolbarHidden(false, animated: false) - } - - var isWidgetCachedFeaturedArticle: Bool { - let sharedCache = SharedContainerCache(fileName: SharedContainerCacheCommonNames.widgetCache) - - let cache = sharedCache.loadCache() ?? WidgetCache(settings: .default, featuredContent: nil) - guard let widgetFeaturedArticleURLString = cache.featuredContent?.featuredArticle?.contentURL.desktop.page, - let widgetFeaturedArticleURL = URL(string: widgetFeaturedArticleURLString) else { - return false - } - - return widgetFeaturedArticleURL == articleURL - } - -} - -extension ArticleViewController { - func presentEmbedded(_ viewController: UIViewController, style: WMFThemeableNavigationControllerStyle) { - let nc = WMFThemeableNavigationController(rootViewController: viewController, theme: theme, style: style) - present(nc, animated: true) - } -} - -extension ArticleViewController: ReadingThemesControlsResponding { - func updateWebViewTextSize(textSize: Int) { - messagingController.updateTextSizeAdjustmentPercentage(textSize) - } - - func toggleSyntaxHighlighting(_ controller: ReadingThemesControlsViewController) { - // no-op here, syntax highlighting shouldnt be displayed - } -} - -extension ArticleViewController: ImageScaleTransitionProviding { - var imageScaleTransitionView: UIImageView? { - return leadImageView - } - - func prepareViewsForIncomingImageScaleTransition(with imageView: UIImageView?) { - guard let imageView = imageView, let image = imageView.image else { - return - } - - leadImageHeightConstraint.constant = leadImageHeight - leadImageView.image = image - leadImageView.layer.contentsRect = imageView.layer.contentsRect - - view.layoutIfNeeded() - } - -} - -// MARK: - Article Load Errors - -extension ArticleViewController { - func handleArticleLoadFailure(with error: Error, showEmptyView: Bool) { - fakeProgressController.finish() - if showEmptyView { - wmf_showEmptyView(of: .articleDidNotLoad, theme: theme, frame: view.bounds) - } - showError(error) - refreshControl.endRefreshing() - updateRefreshOverlay(visible: false) - } - - func articleLoadDidFail(with error: Error) { - handleArticleLoadFailure(with: error, showEmptyView: !article.isSaved) - } -} - -extension ArticleViewController: WKNavigationDelegate { - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - switch navigationAction.navigationType { - case .reload: - fallthrough - case .other: - setupArticleLoadWaitGroup() - decisionHandler(.allow) - default: - decisionHandler(.cancel) - } - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { - switch navigationAction.navigationType { - case .reload: - fallthrough - case .other: - setupArticleLoadWaitGroup() - decisionHandler(.allow, preferences) - default: - decisionHandler(.cancel, preferences) - } - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { - defer { - decisionHandler(.allow) - } - guard let response = navigationResponse.response as? HTTPURLResponse else { - return - } - currentETag = response.allHeaderFields[HTTPURLResponse.etagHeaderKey] as? String - checkForScrollToAnchor(in: response) - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - articleLoadDidFail(with: error) - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - articleLoadDidFail(with: error) - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - // On process did terminate, the WKWebView goes blank - // Re-load the content in this case to show it again - webView.reload() - } - - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - if shouldPerformWebRefreshAfterScrollViewDeceleration { - updateRefreshOverlay(visible: false) - webView.scrollView.showsVerticalScrollIndicator = true - shouldPerformWebRefreshAfterScrollViewDeceleration = false - } - } -} - -extension ViewController { // Putting extension on ViewController rather than ArticleVC allows for re-use by EditPreviewVC - - var articleMargins: UIEdgeInsets { - return UIEdgeInsets(top: 8, left: articleHorizontalMargin, bottom: 0, right: articleHorizontalMargin) - } - - var articleHorizontalMargin: CGFloat { - let viewForCalculation: UIView = navigationController?.view ?? view - - if let tableOfContentsVC = (self as? ArticleViewController)?.tableOfContentsController.viewController, tableOfContentsVC.isVisible { - // full width - return viewForCalculation.layoutMargins.left - } else { - // If (is EditPreviewVC) or (is TOC OffScreen) then use readableContentGuide to make text inset from screen edges. - // Since readableContentGuide has no effect on compact width, both paths of this `if` statement result in an identical result for smaller screens. - return viewForCalculation.readableContentGuide.layoutFrame.minX - } - } -} - -// MARK: Article As Living Doc Protocols - -extension ArticleViewController: ArticleAsLivingDocViewControllerDelegate { - func livingDocViewWillPush() { - surveyTimerController?.livingDocViewWillPush(withState: state) - } - - func livingDocViewWillAppear() { - surveyTimerController?.livingDocViewWillAppear(withState: state) - } - - var articleAsLivingDocViewModel: ArticleAsLivingDocViewModel? { - return articleAsLivingDocController.articleAsLivingDocViewModel - } - - func fetchNextPage(nextRvStartId: UInt, theme: Theme) { - articleAsLivingDocController.fetchNextPage(nextRvStartId: nextRvStartId, traitCollection: traitCollection, theme: theme) - } - - var isFetchingAdditionalPages: Bool { - return articleAsLivingDocController.isFetchingAdditionalPages - } -} - -extension ArticleViewController: ArticleAsLivingDocControllerDelegate { - var abTestsController: ABTestsController { - return dataStore.abTestsController - } - - var isInValidSurveyCampaignAndArticleList: Bool { - surveyAnnouncementResult != nil - } - - func extendTimerForPresentingModal() { - surveyTimerController?.extendTimer() - } -} - -extension ArticleViewController: ArticleSurveyTimerControllerDelegate { - var displayDelay: TimeInterval? { - surveyAnnouncementResult?.displayDelay - } - - var shouldAttemptToShowArticleAsLivingDoc: Bool { - return articleAsLivingDocController.shouldAttemptToShowArticleAsLivingDoc - } - - var userHasSeenSurveyPrompt: Bool { - - guard let identifier = surveyAnnouncementResult?.campaignIdentifier else { - return false - } - - return SurveyAnnouncementsController.shared.userHasSeenSurveyPrompt(forCampaignIdentifier: identifier) - } - - var shouldShowArticleAsLivingDoc: Bool { - return articleAsLivingDocController.shouldShowArticleAsLivingDoc - } - - var livingDocSurveyLinkState: ArticleAsLivingDocSurveyLinkState { - return articleAsLivingDocController.surveyLinkState - } - - -} - -extension ArticleViewController: UISheetPresentationControllerDelegate { - func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { - - guard altTextExperimentViewModel != nil else { - return - } - - let oldContentInset = webView.scrollView.contentInset - - if let selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier { - switch selectedDetentIdentifier { - case .medium, .large: - webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: view.bounds.height * 0.65, right: oldContentInset.right) - default: - logMinimized() - webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: 75, right: oldContentInset.right) - } - } - } - - private func logMinimized() { - if let project = project { - EditInteractionFunnel.shared.logAltTextInputDidMinimize(project: project) - } - } -} - -extension ArticleViewController: WMFAltTextExperimentModalSheetLoggingDelegate { - - func didTriggerCharacterWarning() { - if let project = project { - EditInteractionFunnel.shared.logAltTextInputDidTriggerWarning(project: project) - } - } - - func didTapFileName() { - if let project = project { - EditInteractionFunnel.shared.logAltTextInputDidTapFileName(project: project) - } - } - - func didAppear() { - if let project = project { - EditInteractionFunnel.shared.logAltTextInputDidAppear(project: project) - } - } - - func didFocusTextView() { - if let project = project { - EditInteractionFunnel.shared.logAltTextInputDidFocus(project: project) - } - } + internal lazy var readingListsFunnel = ReadingListsFunnel.shared } diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index ae4dd489b73..2da1679abbc 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -1293,10 +1293,10 @@ extension ExploreViewController: WMFImageRecommendationsDelegate { return } - guard let imageWikitext = lastRecommendation.imageWikitext, - let fullArticleWikitextWithImage = lastRecommendation.fullArticleWikitextWithImage, - let lastRevisionID = lastRecommendation.lastRevisionID, - let localizedFileTitle = lastRecommendation.localizedFileTitle else { + guard let _ = lastRecommendation.imageWikitext, + let _ = lastRecommendation.fullArticleWikitextWithImage, + let _ = lastRecommendation.lastRevisionID, + let _ = lastRecommendation.localizedFileTitle else { return }