diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index c46cad6374..16388b6cf0 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1416,6 +1416,8 @@ 560C3FFD2BC9911000F589CE /* PermanentSurveyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560C3FFB2BC9911000F589CE /* PermanentSurveyManagerTests.swift */; }; 560C3FFF2BCD5A1E00F589CE /* PermanentSurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560C3FFE2BCD5A1E00F589CE /* PermanentSurveyManager.swift */; }; 560C40002BCD5A1E00F589CE /* PermanentSurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560C3FFE2BCD5A1E00F589CE /* PermanentSurveyManager.swift */; }; + 5614B3A12BBD639D009B5031 /* ZoomPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5614B3A02BBD639D009B5031 /* ZoomPopover.swift */; }; + 5614B3A22BBD639D009B5031 /* ZoomPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5614B3A02BBD639D009B5031 /* ZoomPopover.swift */; }; 561D29C22BDA745A007B91D0 /* MockSyncPausedStateManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561D29C02BDA7430007B91D0 /* MockSyncPausedStateManaging.swift */; }; 561D29C32BDA745B007B91D0 /* MockSyncPausedStateManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561D29C02BDA7430007B91D0 /* MockSyncPausedStateManaging.swift */; }; 561D29C62BDA74ED007B91D0 /* MockDDGSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561D29C42BDA749A007B91D0 /* MockDDGSyncing.swift */; }; @@ -1424,6 +1426,8 @@ 561D29CB2BDA7530007B91D0 /* MockAppearancePreferencesPersistor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561D29C82BDA751E007B91D0 /* MockAppearancePreferencesPersistor.swift */; }; 561D66662B95C45A008ACC5C /* Suggestion.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 561D66692B95C45A008ACC5C /* Suggestion.storyboard */; }; 561D66672B95C45A008ACC5C /* Suggestion.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 561D66692B95C45A008ACC5C /* Suggestion.storyboard */; }; + 562532A02BC069180034D316 /* ZoomPopoverViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5625329D2BC069100034D316 /* ZoomPopoverViewModelTests.swift */; }; + 562532A12BC069190034D316 /* ZoomPopoverViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5625329D2BC069100034D316 /* ZoomPopoverViewModelTests.swift */; }; 562984702AC4610100AC20EB /* SyncPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */; }; 562984712AC469E400AC20EB /* SyncPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */; }; 56534DED29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */; }; @@ -3269,10 +3273,12 @@ 5603D90529B7B746007F9F01 /* MockTabViewItemDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTabViewItemDelegate.swift; sourceTree = ""; }; 560C3FFB2BC9911000F589CE /* PermanentSurveyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermanentSurveyManagerTests.swift; sourceTree = ""; }; 560C3FFE2BCD5A1E00F589CE /* PermanentSurveyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermanentSurveyManager.swift; sourceTree = ""; }; + 5614B3A02BBD639D009B5031 /* ZoomPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomPopover.swift; sourceTree = ""; }; 561D29C02BDA7430007B91D0 /* MockSyncPausedStateManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSyncPausedStateManaging.swift; sourceTree = ""; }; 561D29C42BDA749A007B91D0 /* MockDDGSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDDGSyncing.swift; sourceTree = ""; }; 561D29C82BDA751E007B91D0 /* MockAppearancePreferencesPersistor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppearancePreferencesPersistor.swift; sourceTree = ""; }; 561D66692B95C45A008ACC5C /* Suggestion.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Suggestion.storyboard; sourceTree = ""; }; + 5625329D2BC069100034D316 /* ZoomPopoverViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomPopoverViewModelTests.swift; sourceTree = ""; }; 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPreferencesTests.swift; sourceTree = ""; }; 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingDefaultBrowserProvider.swift; sourceTree = ""; }; 565E46DD2B2725DC0013AC2A /* SyncE2EUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncE2EUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -6944,6 +6950,7 @@ AA7EB6EE27E880EA00036718 /* Animations */, AAC5E4F025D6BF10007F5990 /* AddressBarButton.swift */, AAC5E4F525D6BF2C007F5990 /* AddressBarButtonsViewController.swift */, + 5614B3A02BBD639D009B5031 /* ZoomPopover.swift */, AABEE6AE24AD22B90043105B /* AddressBarTextField.swift */, B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */, B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */, @@ -7062,6 +7069,7 @@ isa = PBXGroup; children = ( AAC9C01B24CB594C00AD1325 /* TabViewModelTests.swift */, + 5625329D2BC069100034D316 /* ZoomPopoverViewModelTests.swift */, ); path = ViewModel; sourceTree = ""; @@ -9509,6 +9517,7 @@ B6E3E5512BBFCDEE00A41922 /* OpenDownloadsCellView.swift in Sources */, 3706FAB0293F65D500E42796 /* BookmarkOutlineCellView.swift in Sources */, 3706FAB1293F65D500E42796 /* UnprotectedDomains.xcdatamodeld in Sources */, + 5614B3A22BBD639D009B5031 /* ZoomPopover.swift in Sources */, 85393C872A6FF1B600F11EB3 /* BookmarksBarAppearance.swift in Sources */, 3706FAB2293F65D500E42796 /* TabInstrumentation.swift in Sources */, 3706FAB5293F65D500E42796 /* ConfigurationManager.swift in Sources */, @@ -10344,6 +10353,7 @@ 3706FE27293F661700E42796 /* AppPrivacyConfigurationTests.swift in Sources */, B626A7652992506A00053070 /* SerpHeadersNavigationResponderTests.swift in Sources */, 9F26060C2B85C20B00819292 /* AddEditBookmarkDialogViewModelTests.swift in Sources */, + 562532A12BC069190034D316 /* ZoomPopoverViewModelTests.swift in Sources */, 3706FE28293F661700E42796 /* BookmarkTests.swift in Sources */, 3706FE29293F661700E42796 /* SuggestionContainerViewModelTests.swift in Sources */, 1D8C2FEB2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, @@ -11490,6 +11500,7 @@ AABEE69C24A902BB0043105B /* SuggestionContainer.swift in Sources */, B6C00ECD292F89D9009C73A6 /* FindInPageTabExtension.swift in Sources */, 85589E8327BBB8630038AD11 /* HomePageViewController.swift in Sources */, + 5614B3A12BBD639D009B5031 /* ZoomPopover.swift in Sources */, B6A9E46B2614618A0067D1B9 /* OperatingSystemVersionExtension.swift in Sources */, 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */, 1D36F4242A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, @@ -11767,6 +11778,7 @@ 4BA1A6FE258C5C1300F6F690 /* EncryptedValueTransformerTests.swift in Sources */, 85F69B3C25EDE81F00978E59 /* URLExtensionTests.swift in Sources */, 4B9292BA2667103100AD2C21 /* BookmarkNodePathTests.swift in Sources */, + 562532A02BC069180034D316 /* ZoomPopoverViewModelTests.swift in Sources */, 4B9292C02667103100AD2C21 /* BookmarkManagedObjectTests.swift in Sources */, 373A1AB228451ED400586521 /* BookmarksHTMLImporterTests.swift in Sources */, 4B723E0626B0003E00E14D75 /* CSVParserTests.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Images/ZoomIncrease.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/ZoomIncrease.imageset/Contents.json new file mode 100644 index 0000000000..03210e711c --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/ZoomIncrease.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Zoom-Page-Increase-16D.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/ZoomIncrease.imageset/Zoom-Page-Increase-16D.svg b/DuckDuckGo/Assets.xcassets/Images/ZoomIncrease.imageset/Zoom-Page-Increase-16D.svg new file mode 100644 index 0000000000..1176ff5c2e --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/ZoomIncrease.imageset/Zoom-Page-Increase-16D.svg @@ -0,0 +1,4 @@ + + + + diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index b7ace49675..697b0dbd32 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -490,6 +490,7 @@ struct UserText { static let mobileBookmarksImportedFolderTitle = NSLocalizedString("bookmarks.imported.mobile.folder.title", value: "Mobile bookmarks", comment: "Name of the \"Mobile bookmarks\" folder imported from other browser") static let zoom = NSLocalizedString("zoom", value: "Zoom", comment: "Menu with Zooming commands") + static let resetZoom = NSLocalizedString("reset-zoom", value: "Reset", comment: "Button that allows the user to reset the zoom level of the browser page") static let emailOptionsMenuItem = NSLocalizedString("email.optionsMenu", value: "Email Protection", comment: "Menu item email feature") static let emailOptionsMenuCreateAddressSubItem = NSLocalizedString("email.optionsMenu.createAddress", value: "Generate Private Duck Address", comment: "Create an email alias sub menu item") diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 7579b0c4ea..5c72907b8e 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -94,6 +94,7 @@ public struct UserDefaultsWrapper { case switchToNewTabWhenOpened = "preferences.tabs.switch-to-new-tab-when-opened" case newTabPosition = "preferences.tabs.new-tab-position" case defaultPageZoom = "preferences.appearance.default-page-zoom" + case websitePageZoom = "preferences.appearance.website-page-zoom" case bookmarksBarAppearance = "preferences.appearance.bookmarks-bar" case homeButtonPosition = "preferences.appeareance.home-button-position" diff --git a/DuckDuckGo/Fire/Model/Fire.swift b/DuckDuckGo/Fire/Model/Fire.swift index 29d355c9cd..a89385145b 100644 --- a/DuckDuckGo/Fire/Model/Fire.swift +++ b/DuckDuckGo/Fire/Model/Fire.swift @@ -30,6 +30,7 @@ final class Fire { let webCacheManager: WebCacheManager let historyCoordinating: HistoryCoordinating let permissionManager: PermissionManagerProtocol + let savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating let downloadListCoordinator: DownloadListCoordinator let windowControllerManager: WindowControllersManager let faviconManagement: FaviconManagement @@ -88,6 +89,7 @@ final class Fire { init(cacheManager: WebCacheManager = WebCacheManager.shared, historyCoordinating: HistoryCoordinating = HistoryCoordinator.shared, permissionManager: PermissionManagerProtocol = PermissionManager.shared, + savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating = AccessibilityPreferences.shared, downloadListCoordinator: DownloadListCoordinator = DownloadListCoordinator.shared, windowControllerManager: WindowControllersManager = WindowControllersManager.shared, faviconManagement: FaviconManagement = FaviconManager.shared, @@ -104,6 +106,7 @@ final class Fire { self.webCacheManager = cacheManager self.historyCoordinating = historyCoordinating self.permissionManager = permissionManager + self.savedZoomLevelsCoordinating = savedZoomLevelsCoordinating self.downloadListCoordinator = downloadListCoordinator self.windowControllerManager = windowControllerManager self.faviconManagement = faviconManagement @@ -167,6 +170,7 @@ final class Fire { self.burnRecentlyClosed(baseDomains: domains) self.burnAutoconsentCache() + self.burnZoomLevels(of: domains) group.notify(queue: .main) { self.dispatchGroup = nil @@ -218,6 +222,7 @@ final class Fire { self.burnRecentlyClosed() self.burnAutoconsentCache() + self.burnZoomLevels() group.notify(queue: .main) { self.dispatchGroup = nil @@ -370,6 +375,16 @@ final class Fire { historyCoordinating.burnAll(completion: completion) } + // MARK: - Zoom levels + + private func burnZoomLevels() { + savedZoomLevelsCoordinating.burnZoomLevels(except: FireproofDomains.shared) + } + + private func burnZoomLevels(of baseDomains: Set) { + savedZoomLevelsCoordinating.burnZoomLevel(of: baseDomains) + } + // MARK: - Permissions private func burnPermissions(completion: @escaping () -> Void) { diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 673ca12212..98f5216530 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -47398,6 +47398,66 @@ } } }, + "reset-zoom" : { + "comment" : "Button that allows the user to reset the zoom level of the browser page", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reset" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Herstel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetowanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить" + } + } + } + }, "restart.bitwarden" : { "comment" : "Button to restart Bitwarden application", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift index f2a57a1c01..b5134a73be 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift @@ -34,6 +34,8 @@ final class AddressBarButtonsViewController: NSViewController { weak var delegate: AddressBarButtonsViewControllerDelegate? + private let accessibilityPreferences: AccessibilityPreferences + private var permissionAuthorizationPopover: PermissionAuthorizationPopover? private func permissionAuthorizationPopoverCreatingIfNeeded() -> PermissionAuthorizationPopover { return permissionAuthorizationPopover ?? { @@ -53,6 +55,7 @@ final class AddressBarButtonsViewController: NSViewController { }() } + @IBOutlet weak var zoomButton: NSButton! @IBOutlet weak var privacyEntryPointButton: MouseOverAnimationButton! @IBOutlet weak var bookmarkButton: AddressBarButton! @IBOutlet weak var imageButtonWrapper: NSView! @@ -66,6 +69,7 @@ final class AddressBarButtonsViewController: NSViewController { var trackerAnimationView3: LottieAnimationView! var shieldAnimationView: LottieAnimationView! var shieldDotAnimationView: LottieAnimationView! + @IBOutlet weak var notificationAnimationView: NavigationBarBadgeAnimationView! @IBOutlet weak var permissionButtons: NSView! @@ -108,7 +112,12 @@ final class AddressBarButtonsViewController: NSViewController { @Published private(set) var buttonsWidth: CGFloat = 0 private var tabCollectionViewModel: TabCollectionViewModel - private var tabViewModel: TabViewModel? + private var tabViewModel: TabViewModel? { + didSet { + subscribeToTabZoomLevel() + } + } + private let popovers: NavigationBarPopovers private var bookmarkManager: BookmarkManager = LocalBookmarkManager.shared @@ -139,6 +148,7 @@ final class AddressBarButtonsViewController: NSViewController { private var urlCancellable: AnyCancellable? private var bookmarkListCancellable: AnyCancellable? private var effectiveAppearanceCancellable: AnyCancellable? + private var accessibilityPreferencesCancellable: AnyCancellable? private var permissionsCancellables = Set() private var trackerAnimationTriggerCancellable: AnyCancellable? private var privacyEntryPointIconUpdateCancellable: AnyCancellable? @@ -152,10 +162,11 @@ final class AddressBarButtonsViewController: NSViewController { init?(coder: NSCoder, tabCollectionViewModel: TabCollectionViewModel, + accessibilityPreferences: AccessibilityPreferences = AccessibilityPreferences.shared, popovers: NavigationBarPopovers) { self.tabCollectionViewModel = tabCollectionViewModel + self.accessibilityPreferences = accessibilityPreferences self.popovers = popovers - super.init(coder: coder) } @@ -257,6 +268,27 @@ final class AddressBarButtonsViewController: NSViewController { bookmarkButton.isShown = shouldShowBookmarkButton } + private func updateZoomButtonVisibility(animation: Bool = false) { + zoomButton.isHidden = true + let hasURL = tabViewModel?.tab.url != nil + let isEditingMode = controllerMode?.isEditing ?? false + let isTextFieldValueText = textFieldValue?.isText ?? false + + var hasNonDefaultZoom = false + if tabViewModel?.zoomLevel != accessibilityPreferences.defaultPageZoom { + hasNonDefaultZoom = true + } + + let shouldShowZoom = hasURL + && !isEditingMode + && !isTextFieldValueText + && !isTextFieldEditorFirstResponder + && !animation + && (hasNonDefaultZoom || popovers.isZoomPopoverShown == true || popovers.zoomPopover != nil) + + zoomButton.isHidden = !shouldShowZoom + } + func openBookmarkPopover(setFavorite: Bool, accessPoint: GeneralPixel.AccessPoint) { let result = bookmarkForCurrentUrl(setFavorite: setFavorite, accessPoint: accessPoint) guard let bookmark = result.bookmark else { @@ -330,6 +362,16 @@ final class AddressBarButtonsViewController: NSViewController { updateImageButton() updatePermissionButtons() updateBookmarkButtonVisibility() + updateZoomButtonVisibility() + } + + @IBAction func zoomButtonAction(_ sender: Any) { + if popovers.isZoomPopoverShown { + popovers.closeZoomPopover() + } else { + guard let tabViewModel = tabCollectionViewModel.selectedTabViewModel else { return } + popovers.showZoomPopover(for: tabViewModel, from: zoomButton, withDelegate: self) + } } @IBAction func cameraButtonAction(_ sender: NSButton) { @@ -753,8 +795,10 @@ final class AddressBarButtonsViewController: NSViewController { } animationView.isHidden = false - animationView.play { _ in + updateZoomButtonVisibility(animation: true) + animationView.play { [weak self] _ in animationView.isHidden = true + self?.updateZoomButtonVisibility(animation: false) } default: return @@ -773,11 +817,13 @@ final class AddressBarButtonsViewController: NSViewController { } trackerAnimationView?.isHidden = false trackerAnimationView?.reloadImages() + self.updateZoomButtonVisibility(animation: true) trackerAnimationView?.play { [weak self] _ in trackerAnimationView?.isHidden = true self?.updatePrivacyEntryPointIcon() self?.updatePermissionButtons() self?.playBadgeAnimationIfNecessary() + self?.updateZoomButtonVisibility(animation: false) } } @@ -862,6 +908,14 @@ final class AddressBarButtonsViewController: NSViewController { .sink { [weak self] _ in self?.setupAnimationViews() self?.updatePrivacyEntryPointIcon() + self?.updateZoomButtonVisibility() + } + } + + private func subscribeToTabZoomLevel() { + accessibilityPreferencesCancellable = tabViewModel?.zoomLevelSubject + .sink { [weak self] _ in + self?.updateZoomButtonVisibility() } } @@ -915,8 +969,10 @@ extension AddressBarButtonsViewController: NSPopoverDelegate { NotificationCenter.default.post(name: .bookmarkPromptShouldShow, object: nil) } updateBookmarkButtonVisibility() - - default: break + case popovers.zoomPopover: + updateZoomButtonVisibility() + default: + break } } diff --git a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift index 51c3ca308a..e83b43b17f 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift @@ -147,6 +147,7 @@ final class AddressBarViewController: NSViewController { registerForMouseEnteredAndExitedEvents() subscribeToButtonsWidth() subscribeForShadowViewUpdates() + } // swiftlint:disable notification_center_detachment diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 8adccabf28..f2092a4641 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -39,6 +39,7 @@ protocol OptionsButtonMenuDelegate: AnyObject { func optionsButtonMenuRequestedPrint(_ menu: NSMenu) func optionsButtonMenuRequestedPreferences(_ menu: NSMenu) func optionsButtonMenuRequestedAppearancePreferences(_ menu: NSMenu) + func optionsButtonMenuRequestedAccessibilityPreferences(_ menu: NSMenu) #if DBP func optionsButtonMenuRequestedDataBrokerProtection(_ menu: NSMenu) #endif @@ -223,6 +224,10 @@ final class MoreOptionsMenu: NSMenu { actionDelegate?.optionsButtonMenuRequestedAppearancePreferences(self) } + @objc func openAccessibilityPreferences(_ sender: NSMenuItem) { + actionDelegate?.optionsButtonMenuRequestedAccessibilityPreferences(self) + } + @objc func openSubscriptionPurchasePage(_ sender: NSMenuItem) { actionDelegate?.optionsButtonMenuRequestedSubscriptionPurchasePage(self) } @@ -606,7 +611,7 @@ final class ZoomSubMenu: NSMenu { addItem(.separator()) - let globalZoomSettingItem = NSMenuItem(title: UserText.defaultZoomPageMoreOptionsItem, action: #selector(MoreOptionsMenu.openAppearancePreferences(_:)), keyEquivalent: "") + let globalZoomSettingItem = NSMenuItem(title: UserText.defaultZoomPageMoreOptionsItem, action: #selector(MoreOptionsMenu.openAccessibilityPreferences(_:)), keyEquivalent: "") .targetting(target) addItem(globalZoomSettingItem) } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard index ad0980e11b..a73ff36fd3 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard +++ b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard @@ -1,5 +1,5 @@ - + @@ -692,6 +692,30 @@ + @@ -828,11 +852,13 @@ + + @@ -864,6 +890,7 @@ + @@ -893,6 +920,7 @@ + diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 8480ad9298..fe1c986a92 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -65,6 +65,9 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { private(set) var bookmarkPopover: AddBookmarkPopover? private weak var bookmarkPopoverDelegate: NSPopoverDelegate? + private (set) var zoomPopover: ZoomPopover? + private weak var zoomPopoverDelegate: NSPopoverDelegate? + private let networkProtectionPopoverManager: NetPPopoverManager private var popoverIsShownCancellables = Set() @@ -109,6 +112,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { bookmarkPopover?.isShown ?? false } + var isZoomPopoverShown: Bool { + zoomPopover?.isShown ?? false + } + func bookmarksButtonPressed(_ button: MouseOverButton, popoverDelegate delegate: NSPopoverDelegate, tab: Tab?) { if bookmarkListPopoverShown { bookmarkListPopover?.close() @@ -200,6 +207,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { bookmarkPopover?.close() } + if zoomPopover?.isShown ?? false { + zoomPopover?.close() + } + if privacyDashboardPopover?.isShown ?? false { privacyDashboardPopover?.close() } @@ -234,10 +245,24 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { show(bookmarkPopover, positionedBelow: button) } + func showZoomPopover(for tabViewModel: TabViewModel, from button: NSButton, withDelegate delegate: NSPopoverDelegate) { + guard closeTransientPopovers() else { return } + + let zoomPopover = ZoomPopover(tabViewModel: tabViewModel) + zoomPopover.delegate = self + self.zoomPopover = zoomPopover + self.zoomPopoverDelegate = delegate + show(zoomPopover, positionedBelow: button) + } + func closeEditBookmarkPopover() { bookmarkPopover?.close() } + func closeZoomPopover() { + zoomPopover?.close() + } + func openPrivacyDashboard(for tabViewModel: TabViewModel, from button: MouseOverButton) { guard closeTransientPopovers() else { return } @@ -448,6 +473,10 @@ extension NavigationBarPopovers: NSPopoverDelegate { privacyInfoCancellable = nil privacyDashboadPendingUpdatesCancellable = nil + case zoomPopover: + zoomPopoverDelegate?.popoverDidClose?(notification) + zoomPopover = nil + default: break } } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 4e438ad79f..0bb271c595 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -1026,6 +1026,10 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate { WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .appearance) } + func optionsButtonMenuRequestedAccessibilityPreferences(_ menu: NSMenu) { + WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .accessibility) + } + func optionsButtonMenuRequestedSubscriptionPurchasePage(_ menu: NSMenu) { WindowControllersManager.shared.showTab(with: .subscription(.subscriptionPurchase)) PixelKit.fire(PrivacyProPixel.privacyProOfferScreenImpression) diff --git a/DuckDuckGo/NavigationBar/View/ZoomPopover.swift b/DuckDuckGo/NavigationBar/View/ZoomPopover.swift new file mode 100644 index 0000000000..c5e8af65f2 --- /dev/null +++ b/DuckDuckGo/NavigationBar/View/ZoomPopover.swift @@ -0,0 +1,157 @@ +// +// ZoomPopover.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +struct ZoomPopoverContentView: View { + @ObservedObject var viewModel: ZoomPopoverViewModel + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Text(viewModel.zoomLevel.displayString) + .frame(width: 50, height: 28) + .padding(.horizontal, 8) + + Button { + viewModel.reset() + } label: { + Text(UserText.resetZoom) + .frame(height: 28) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 8) + } + + HStack(spacing: 1) { + Button { + viewModel.zoomOut() + } label: { + Image(systemName: "minus") + .frame(width: 32, height: 28) + } + Button { + viewModel.zoomIn() + } label: { + Image(systemName: "plus") + .frame(width: 32, height: 28) + } + } + } + .frame(height: 52) + .padding(.horizontal, 16) + } +} + +final class ZoomPopoverViewModel: ObservableObject { + let tabViewModel: TabViewModel + @Published var zoomLevel: DefaultZoomValue = .percent100 + private var cancellables = Set() + + init(tabViewModel: TabViewModel) { + self.tabViewModel = tabViewModel + zoomLevel = tabViewModel.zoomLevel + tabViewModel.zoomLevelSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + self?.zoomLevel = newValue + }.store(in: &cancellables) + } + + func zoomIn() { + tabViewModel.tab.webView.zoomIn() + } + + func zoomOut() { + tabViewModel.tab.webView.zoomOut() + } + + func reset() { + tabViewModel.tab.webView.resetZoomLevel() + } + +} + +final class ZoomPopoverViewController: NSViewController { + let viewModel: ZoomPopoverViewModel + + init(viewModel: ZoomPopoverViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let swiftUIView = ZoomPopoverContentView(viewModel: viewModel) + view = NSHostingView(rootView: swiftUIView) + } +} + +final class ZoomPopover: NSPopover { + + var tabViewModel: TabViewModel + + private weak var addressBar: NSView? + + /// prefferred bounding box for the popover positioning + override var boundingFrame: NSRect { + guard let addressBar, + let window = addressBar.window else { return .infinite } + var frame = window.convertToScreen(addressBar.convert(addressBar.bounds, to: nil)) + frame = frame.insetBy(dx: 0, dy: -window.frame.size.height) + return frame + } + + /// position popover to the right + override func adjustFrame(_ frame: NSRect) -> NSRect { + let boundingFrame = self.boundingFrame + guard !boundingFrame.isInfinite else { return frame } + var frame = frame + frame.origin.x = boundingFrame.minX + return frame + } + + init(tabViewModel: TabViewModel) { + self.tabViewModel = tabViewModel + super.init() + + self.animates = false + self.behavior = .semitransient + setupContentController() + } + + required init?(coder: NSCoder) { + fatalError("BookmarksPopover: Bad initializer") + } + + // swiftlint:disable force_cast + var viewController: ZoomPopoverViewController { contentViewController as! ZoomPopoverViewController } + // swiftlint:enable force_cast + + private func setupContentController() { + let controller = ZoomPopoverViewController(viewModel: ZoomPopoverViewModel(tabViewModel: tabViewModel)) + contentViewController = controller + } + + override func show(relativeTo positioningRect: NSRect, of positioningView: NSView, preferredEdge: NSRectEdge) { + self.addressBar = positioningView.superview + super.show(relativeTo: positioningRect, of: positioningView, preferredEdge: preferredEdge) + } +} diff --git a/DuckDuckGo/Preferences/Model/AccessibilityPreferences.swift b/DuckDuckGo/Preferences/Model/AccessibilityPreferences.swift index c096191757..dc4f32294a 100644 --- a/DuckDuckGo/Preferences/Model/AccessibilityPreferences.swift +++ b/DuckDuckGo/Preferences/Model/AccessibilityPreferences.swift @@ -20,14 +20,24 @@ import Foundation import AppKit import Bookmarks import Common +import Combine protocol AccessibilityPreferencesPersistor { var defaultPageZoom: CGFloat { get set } + var zoomPerWebsite: [String: CGFloat] { get set } +} + +protocol SavedZoomLevelsCoordinating { + func burnZoomLevels(except fireproofDomains: FireproofDomains) + func burnZoomLevel(of baseDomains: Set) } struct AccessibilityPreferencesUserDefaultsPersistor: AccessibilityPreferencesPersistor { @UserDefaultsWrapper(key: .defaultPageZoom, defaultValue: DefaultZoomValue.percent100.rawValue) var defaultPageZoom: CGFloat + + @UserDefaultsWrapper(key: .websitePageZoom, defaultValue: [:]) + var zoomPerWebsite: [String: CGFloat] } enum DefaultZoomValue: CGFloat, CaseIterable { @@ -60,10 +70,47 @@ final class AccessibilityPreferences: ObservableObject { } } + let zoomPerWebsiteUpdatedSubject = PassthroughSubject() + private var zoomPerWebsite: [String: DefaultZoomValue] { + didSet { + persistor.zoomPerWebsite = zoomPerWebsite.mapValues { $0.rawValue } + zoomPerWebsiteUpdatedSubject.send() + } + } + + private var persistor: AccessibilityPreferencesPersistor + init(persistor: AccessibilityPreferencesPersistor = AccessibilityPreferencesUserDefaultsPersistor()) { self.persistor = persistor defaultPageZoom = .init(rawValue: persistor.defaultPageZoom) ?? .percent100 + zoomPerWebsite = persistor.zoomPerWebsite.compactMapValues { DefaultZoomValue(rawValue: $0) } } - private var persistor: AccessibilityPreferencesPersistor + func zoomPerWebsite(url: String) -> DefaultZoomValue? { + guard let domain = TLD().eTLDplus1(forStringURL: url) else { return nil } + return zoomPerWebsite[domain] + } + + func updateZoomPerWebsite(zoomLevel: DefaultZoomValue, url: String) { + guard let domain = TLD().eTLDplus1(forStringURL: url) else { return } + if zoomLevel == defaultPageZoom { + zoomPerWebsite[domain] = nil + } else { + zoomPerWebsite[domain] = zoomLevel + } + } +} + +extension AccessibilityPreferences: SavedZoomLevelsCoordinating { + func burnZoomLevels(except fireproofDomains: FireproofDomains) { + zoomPerWebsite = zoomPerWebsite.filter { + fireproofDomains.isFireproof(fireproofDomain: $0.key) + } + } + + func burnZoomLevel(of baseDomains: Set) { + for website in zoomPerWebsite.keys where baseDomains.contains(website) { + zoomPerWebsite[website] = nil + } + } } diff --git a/DuckDuckGo/Tab/View/WebView.swift b/DuckDuckGo/Tab/View/WebView.swift index 7de19a7c13..34bf762f67 100644 --- a/DuckDuckGo/Tab/View/WebView.swift +++ b/DuckDuckGo/Tab/View/WebView.swift @@ -30,11 +30,16 @@ protocol WebViewInteractionEventsDelegate: AnyObject { func webView(_ webView: WebView, scrollWheel event: NSEvent) } +protocol WebViewZoomLevelDelegate: AnyObject { + func zoomWasSet(to level: DefaultZoomValue) + } + @objc(DuckDuckGo_WebView) final class WebView: WKWebView { weak var contextMenuDelegate: WebViewContextMenuDelegate? weak var interactionEventsDelegate: WebViewInteractionEventsDelegate? + weak var zoomLevelDelegate: WebViewZoomLevelDelegate? private var isLoadingObserver: Any? @@ -95,6 +100,12 @@ final class WebView: WKWebView { return DefaultZoomValue(rawValue: pageZoom) ?? .percent100 } set { + // There are cases where the pageZoom does not reflect the actual display, such as after a command-click on a link. + // The API may not trigger a change if the new value is the same as the current value. + if pageZoom == newValue.rawValue { + // Slightly modify the value to force the API to trigger a change. + pageZoom = newValue.rawValue - 0.001 + } pageZoom = newValue.rawValue } } @@ -118,16 +129,19 @@ final class WebView: WKWebView { func resetZoomLevel() { magnification = 1 zoomLevel = defaultZoomValue + zoomLevelDelegate?.zoomWasSet(to: zoomLevel) } func zoomIn() { guard canZoomIn else { return } zoomLevel = DefaultZoomValue.allCases[self.zoomLevel.index + 1] + zoomLevelDelegate?.zoomWasSet(to: zoomLevel) } func zoomOut() { guard canZoomOut else { return } zoomLevel = DefaultZoomValue.allCases[self.zoomLevel.index - 1] + zoomLevelDelegate?.zoomWasSet(to: zoomLevel) } // MARK: - Menu diff --git a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift index e9726a5dc0..04e3d1e4cb 100644 --- a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift +++ b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift @@ -75,6 +75,16 @@ final class TabViewModel { @Published private(set) var usedPermissions = Permissions() @Published private(set) var permissionAuthorizationQuery: PermissionAuthorizationQuery? + let zoomLevelSubject = PassthroughSubject() + private (set) var zoomLevel: DefaultZoomValue = .percent100 { + didSet { + self.tab.webView.zoomLevel = zoomLevel + if oldValue != zoomLevel { + zoomLevelSubject.send(zoomLevel) + } + } + } + var canPrint: Bool { !isShowingErrorPage && canReload && tab.webView.canPrint } @@ -102,7 +112,7 @@ final class TabViewModel { self.tab = tab self.appearancePreferences = appearancePreferences self.accessibilityPreferences = accessibilityPreferences - + zoomLevel = accessibilityPreferences.defaultPageZoom subscribeToUrl() subscribeToCanGoBackForwardAndReload() subscribeToTitle() @@ -131,7 +141,7 @@ final class TabViewModel { return Empty().eraseToAnyPublisher() case .url(let url, _, source: .webViewUpdated), - .url(let url, _, source: .link): + .url(let url, _, source: .link): guard !url.isEmpty, url != .blankPage, !url.isDuckPlayer else { fallthrough } @@ -144,21 +154,21 @@ final class TabViewModel { .asVoid().eraseToAnyPublisher() case .url(_, _, source: .pendingStateRestoration), - .url(_, _, source: .loadedByStateRestoration), - .url(_, _, source: .userEntered), - .url(_, _, source: .historyEntry), - .url(_, _, source: .bookmark), - .url(_, _, source: .ui), - .url(_, _, source: .appOpenUrl), - .url(_, _, source: .reload), - .newtab, - .settings, - .bookmarks, - .onboarding, - .none, - .dataBrokerProtection, - .subscription, - .identityTheftRestoration: + .url(_, _, source: .loadedByStateRestoration), + .url(_, _, source: .userEntered), + .url(_, _, source: .historyEntry), + .url(_, _, source: .bookmark), + .url(_, _, source: .ui), + .url(_, _, source: .appOpenUrl), + .url(_, _, source: .reload), + .newtab, + .settings, + .bookmarks, + .onboarding, + .none, + .dataBrokerProtection, + .subscription, + .identityTheftRestoration: // Update the address bar instantly for built-in content types or user-initiated navigations return Just( () ).eraseToAnyPublisher() } @@ -170,6 +180,7 @@ final class TabViewModel { updateAddressBarStrings() updateFavicon() updateCanBeBookmarked() + updateZoomForWebsite() } .store(in: &cancellables) } @@ -231,19 +242,42 @@ final class TabViewModel { } private func subscribeToPreferences() { + self.tab.webView.zoomLevelDelegate = self appearancePreferences.$showFullURL.dropFirst().sink { [weak self] showFullURL in self?.updatePassiveAddressBarString(showFullURL: showFullURL) }.store(in: &cancellables) accessibilityPreferences.$defaultPageZoom.sink { [weak self] newValue in guard let self = self else { return } self.tab.webView.defaultZoomValue = newValue - self.tab.webView.zoomLevel = newValue + if !isThereZoomPerWebsite { + self.zoomLevel = newValue + } }.store(in: &cancellables) + accessibilityPreferences.zoomPerWebsiteUpdatedSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateZoomForWebsite() + }.store(in: &cancellables) + } + + private var isThereZoomPerWebsite: Bool { + guard let urlString = tab.url?.absoluteString else { return false } + guard !tab.burnerMode.isBurner else { return false } + return accessibilityPreferences.zoomPerWebsite(url: urlString) != nil + } + + private func updateZoomForWebsite() { + guard let urlString = tab.url?.absoluteString else { return } + guard !tab.burnerMode.isBurner else { return } + let zoomToApply: DefaultZoomValue = accessibilityPreferences.zoomPerWebsite(url: urlString) ?? accessibilityPreferences.defaultPageZoom + self.zoomLevel = zoomToApply } private func subscribeToWebViewDidFinishNavigation() { tab.webViewDidFinishNavigationPublisher.sink { [weak self] in - self?.sendAnimationTrigger() + guard let self = self else { return } + self.sendAnimationTrigger() + self.updateZoomForWebsite() }.store(in: &cancellables) } @@ -272,21 +306,21 @@ final class TabViewModel { let showFullURL = showFullURL ?? appearancePreferences.showFullURL passiveAddressBarAttributedString = switch tab.content { case .newtab, .onboarding, .none: - .init() // empty + .init() // empty case .settings: - .settingsTrustedIndicator + .settingsTrustedIndicator case .bookmarks: - .bookmarksTrustedIndicator + .bookmarksTrustedIndicator case .dataBrokerProtection: - .dbpTrustedIndicator + .dbpTrustedIndicator case .subscription: - .subscriptionTrustedIndicator + .subscriptionTrustedIndicator case .identityTheftRestoration: - .identityTheftRestorationTrustedIndicator + .identityTheftRestorationTrustedIndicator case .url(let url, _, _) where url.isDuckPlayer: - .duckPlayerTrustedIndicator + .duckPlayerTrustedIndicator case .url(let url, _, _) where url.isEmailProtection: - .emailProtectionTrustedIndicator + .emailProtectionTrustedIndicator case .url(let url, _, _): NSAttributedString(string: passiveAddressBarString(with: url, showFullURL: showFullURL)) } @@ -313,7 +347,7 @@ final class TabViewModel { private func updateTitle() { // swiftlint:disable:this cyclomatic_complexity var title: String switch tab.content { - // keep an old tab title for web page terminated page, display "Failed to open page" for loading errors + // keep an old tab title for web page terminated page, display "Failed to open page" for loading errors case _ where isShowingErrorPage && (tab.error?.code != .webContentProcessTerminated || tab.title == nil): if tab.error?.errorCode == NSURLErrorServerCertificateUntrusted { title = UserText.sslErrorPageTabTitle @@ -386,6 +420,7 @@ final class TabViewModel { func reload() { tab.reload() updateAddressBarStrings() + self.updateZoomForWebsite() } private func errorFaviconToShow(error: WKError?) -> NSImage { @@ -440,6 +475,17 @@ extension TabViewModel: TabDataClearing { } +extension TabViewModel: WebViewZoomLevelDelegate { + func zoomWasSet(to level: DefaultZoomValue) { + zoomLevel = level + guard let urlString = tab.url?.absoluteString else { return } + guard !tab.burnerMode.isBurner else { return } + if accessibilityPreferences.zoomPerWebsite(url: urlString) != level { + accessibilityPreferences.updateZoomPerWebsite(zoomLevel: level, url: urlString) + } + } +} + private extension NSAttributedString { private typealias Component = NSAttributedString diff --git a/IntegrationTests/Tab/AddressBarTests.swift b/IntegrationTests/Tab/AddressBarTests.swift index 9323afb45c..37325e3063 100644 --- a/IntegrationTests/Tab/AddressBarTests.swift +++ b/IntegrationTests/Tab/AddressBarTests.swift @@ -861,6 +861,41 @@ class AddressBarTests: XCTestCase { let shieldImage = mainViewController.navigationBarViewController.addressBarViewController!.addressBarButtonsViewController!.privacyEntryPointButton.image! XCTAssertTrue(shieldImage.isEqualToImage(expectedImage)) } + + @MainActor + func test_ZoomLevelNonDefault_ThenZoomButtonIsVisible() async throws { + // GIVEN + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered(""))) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + viewModel.selectedTabViewModel?.zoomWasSet(to: .percent150) + let tabLoadedPromise = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + // WHEN + window = WindowsManager.openNewWindow(with: viewModel)! + _=try await tabLoadedPromise.value + + // THEN + let zoomButton = mainViewController.navigationBarViewController.addressBarViewController!.addressBarButtonsViewController!.zoomButton! + XCTAssertFalse(zoomButton.isHidden) + } + + @MainActor + func test_ZoomLevelDefault_ThenZoomButtonIsNotVisible() async throws { + // GIVEN + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered(""))) + tab.webView.zoomLevel = AccessibilityPreferences.shared.defaultPageZoom + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + viewModel.selectedTabViewModel?.zoomWasSet(to: .percent100) + let tabLoadedPromise = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + // WHEN + window = WindowsManager.openNewWindow(with: viewModel)! + _=try await tabLoadedPromise.value + + // THEN + let zoomButton = mainViewController.navigationBarViewController.addressBarViewController!.addressBarButtonsViewController!.zoomButton! + XCTAssertTrue(zoomButton.isHidden) + } } protocol MainActorPerformer { diff --git a/UnitTests/Fire/Model/FireTests.swift b/UnitTests/Fire/Model/FireTests.swift index 2d9340bb09..9c4c15898b 100644 --- a/UnitTests/Fire/Model/FireTests.swift +++ b/UnitTests/Fire/Model/FireTests.swift @@ -100,6 +100,7 @@ final class FireTests: XCTestCase { func testWhenBurnAll_ThenAllWebsiteDataAreRemoved() { let manager = WebCacheManagerMock() let historyCoordinator = HistoryCoordinatingMock() + let zoomLevelsCoordinator = MockSavedZoomCoordinator() let permissionManager = PermissionManagerMock() let faviconManager = FaviconManagerMock() let recentlyClosedCoordinator = RecentlyClosedCoordinatorMock() @@ -107,6 +108,7 @@ final class FireTests: XCTestCase { let fire = Fire(cacheManager: manager, historyCoordinating: historyCoordinator, permissionManager: permissionManager, + savedZoomLevelsCoordinating: zoomLevelsCoordinator, windowControllerManager: WindowControllersManager.shared, faviconManagement: faviconManager, recentlyClosedCoordinator: recentlyClosedCoordinator, @@ -124,6 +126,7 @@ final class FireTests: XCTestCase { XCTAssert(historyCoordinator.burnAllCalled) XCTAssert(permissionManager.burnPermissionsCalled) XCTAssert(recentlyClosedCoordinator.burnCacheCalled) + XCTAssert(zoomLevelsCoordinator.burnAllZoomLevelsCalled) } func testWhenBurnAllThenBurningFlagToggles() { @@ -192,6 +195,18 @@ final class FireTests: XCTestCase { XCTAssertFalse(appStateRestorationManager.canRestoreLastSessionState) } + func testWhenBurnDomainsIsCalledThenSelectedDomainsZoomLevelsAreBurned() { + let domainsToBurn: Set = ["test.com", "provola.co.uk"] + let zoomLevelsCoordinator = MockSavedZoomCoordinator() + let fire = Fire(savedZoomLevelsCoordinating: zoomLevelsCoordinator, + tld: ContentBlocking.shared.tld) + + fire.burnEntity(entity: .none(selectedDomains: domainsToBurn)) + + XCTAssertTrue(zoomLevelsCoordinator.burnZoomLevelsOfDomainsCalled) + XCTAssertEqual(zoomLevelsCoordinator.domainsBurned, domainsToBurn) + } + func testWhenBurnVisitIsCalledForTodayThenAllExistingTabsAreCleared() { let manager = WebCacheManagerMock() let historyCoordinator = HistoryCoordinatingMock() @@ -291,3 +306,18 @@ fileprivate extension TabCollectionViewModel { } } + +class MockSavedZoomCoordinator: SavedZoomLevelsCoordinating { + var burnAllZoomLevelsCalled = false + var burnZoomLevelsOfDomainsCalled = false + var domainsBurned: Set = [] + + func burnZoomLevels(except fireproofDomains: DuckDuckGo_Privacy_Browser.FireproofDomains) { + burnAllZoomLevelsCalled = true + } + + func burnZoomLevel(of baseDomains: Set) { + burnZoomLevelsOfDomainsCalled = true + domainsBurned = baseDomains + } +} diff --git a/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift b/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift index 05e9c206e7..7d6145b68f 100644 --- a/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift +++ b/UnitTests/Menus/Mocks/CapturingOptionsButtonMenuDelegate.swift @@ -23,6 +23,7 @@ class CapturingOptionsButtonMenuDelegate: OptionsButtonMenuDelegate { var optionsButtonMenuRequestedPreferencesCalled = false var optionsButtonMenuRequestedAppearancePreferencesCalled = false + var optionsButtonMenuRequestedAccessibilityPreferencesCalled = false var optionsButtonMenuRequestedBookmarkAllOpenTabsCalled = false func optionsButtonMenuRequestedDataBrokerProtection(_ menu: NSMenu) { @@ -96,4 +97,8 @@ class CapturingOptionsButtonMenuDelegate: OptionsButtonMenuDelegate { func optionsButtonMenuRequestedIdentityTheftRestoration(_ menu: NSMenu) { } + + func optionsButtonMenuRequestedAccessibilityPreferences(_ menu: NSMenu) { + optionsButtonMenuRequestedAccessibilityPreferencesCalled = true + } } diff --git a/UnitTests/Menus/MoreOptionsMenuTests.swift b/UnitTests/Menus/MoreOptionsMenuTests.swift index e5ed58d0f8..f9680be11d 100644 --- a/UnitTests/Menus/MoreOptionsMenuTests.swift +++ b/UnitTests/Menus/MoreOptionsMenuTests.swift @@ -112,7 +112,7 @@ final class MoreOptionsMenuTests: XCTestCase { zoomSubmenu.performActionForItem(at: defaultZoomItemIndex) - XCTAssertTrue(capturingActionDelegate.optionsButtonMenuRequestedAppearancePreferencesCalled) + XCTAssertTrue(capturingActionDelegate.optionsButtonMenuRequestedAccessibilityPreferencesCalled) } // MARK: Preferences diff --git a/UnitTests/Preferences/AccessibilityPreferencesTests.swift b/UnitTests/Preferences/AccessibilityPreferencesTests.swift index 819f2c6a0a..8354d7bb1e 100644 --- a/UnitTests/Preferences/AccessibilityPreferencesTests.swift +++ b/UnitTests/Preferences/AccessibilityPreferencesTests.swift @@ -18,27 +18,169 @@ import XCTest @testable import DuckDuckGo_Privacy_Browser - -class MockAccessibilityPreferencesPersistor: AccessibilityPreferencesPersistor { - var defaultPageZoom: CGFloat = DefaultZoomValue.percent100.rawValue -} +import Combine class AccessibilityPreferencesTests: XCTestCase { - func testWhenInitializedThenItLoadsPersistedDefaultPageZoom() { - let mockPersistor = MockAccessibilityPreferencesPersistor() - mockPersistor.defaultPageZoom = DefaultZoomValue.percent150.rawValue + let website1 = "https://www.bbc.com" + let website2 = "https://duckduckgo.com" + let website3 = "https://www.test.com" + let website4 = "https://somesite.it" + let domain1 = "bbc.com" + let domain2 = "duckduckgo.com" + let domain3 = "test.com" + let domain4 = "somesite.it" + + var zoom1: DefaultZoomValue! + var zoom2: DefaultZoomValue! + var zoom3: DefaultZoomValue! + var zoom4: DefaultZoomValue! + var mockPersistor: MockAccessibilityPreferencesPersistor! + private var cancellables = Set() + + override func setUp() { + UserDefaultsWrapper.clearAll() + mockPersistor = MockAccessibilityPreferencesPersistor() + let filteredCases = DefaultZoomValue.allCases.filter { $0 != .percent100 } + zoom1 = filteredCases.randomElement()! + zoom2 = filteredCases.randomElement()! + zoom3 = filteredCases.randomElement()! + zoom4 = filteredCases.randomElement()! + } + + override func tearDown() { + mockPersistor = nil + zoom1 = nil + zoom2 = nil + zoom3 = nil + zoom4 = nil + UserDefaultsWrapper.clearAll() + } + + func test_whenPreferencesInitialized_thenItLoadsPersistedDefaultPageZoom() { + // GIVEN + let randomZoom = DefaultZoomValue.allCases.randomElement()! + mockPersistor.defaultPageZoom = randomZoom.rawValue + + // WHEN let accessibilityPreferences = AccessibilityPreferences(persistor: mockPersistor) - XCTAssertEqual(accessibilityPreferences.defaultPageZoom, DefaultZoomValue.percent150) + // THEN + XCTAssertEqual(accessibilityPreferences.defaultPageZoom, randomZoom) } - func testWhenDefaultPageZoomUpdatedThenPersistorUpdates() { - let mockPersistor = MockAccessibilityPreferencesPersistor() + func test_whenDefaultPageZoomUpdated_ThenPersistorUpdatesDefaultZoom() { + // GIVEN + let randomZoom = DefaultZoomValue.allCases.randomElement()! let accessibilityPreferences = AccessibilityPreferences(persistor: mockPersistor) - accessibilityPreferences.defaultPageZoom = .percent75 - XCTAssertEqual(mockPersistor.defaultPageZoom, DefaultZoomValue.percent75.rawValue) + // WHEN + accessibilityPreferences.defaultPageZoom = randomZoom + + // THEN + XCTAssertEqual(mockPersistor.defaultPageZoom, randomZoom.rawValue) + } + + func test_whenZoomLevelPerWebsiteChangedInPreferences_thenThePersisterAndUserDefaultsZoomPerWebsiteValuesAreUpdated() { + // GIVEN + let expectation = XCTestExpectation() + UserDefaultsWrapper.clearAll() + let persister = AccessibilityPreferencesUserDefaultsPersistor() + let model = AccessibilityPreferences(persistor: persister) + model.zoomPerWebsiteUpdatedSubject + .receive(on: DispatchQueue.main) + .sink { _ in + expectation.fulfill() + }.store(in: &cancellables) + + // WHEN + model.updateZoomPerWebsite(zoomLevel: zoom1, url: website1) + model.updateZoomPerWebsite(zoomLevel: zoom2, url: website2) + wait(for: [expectation], timeout: 5) + + // THEN + XCTAssertEqual(model.zoomPerWebsite(url: website1), zoom1) + XCTAssertEqual(model.zoomPerWebsite(url: website2), zoom2) + XCTAssertEqual(persister.zoomPerWebsite[domain1], zoom1.rawValue) + XCTAssertEqual(persister.zoomPerWebsite[domain2], zoom2.rawValue) } + func test_whenBurningZoomLevels_thenOnlyFireproofSiteZoomLevelAreRetained() { + // GIVEN + let expectation = XCTestExpectation() + UserDefaultsWrapper.clearAll() + let persister = AccessibilityPreferencesUserDefaultsPersistor() + let model = AccessibilityPreferences(persistor: persister) + model.updateZoomPerWebsite(zoomLevel: zoom1, url: website1) + model.updateZoomPerWebsite(zoomLevel: zoom2, url: website2) + model.updateZoomPerWebsite(zoomLevel: zoom3, url: website3) + model.updateZoomPerWebsite(zoomLevel: zoom4, url: website4) + let fireProofDomains = MockFireproofDomains(domains: [website1, website3]) + model.zoomPerWebsiteUpdatedSubject + .receive(on: DispatchQueue.main) + .sink { _ in + expectation.fulfill() + }.store(in: &cancellables) + + // WHEN + model.burnZoomLevels(except: fireProofDomains) + + wait(for: [expectation], timeout: 5) + + // THEN + XCTAssertEqual(model.zoomPerWebsite(url: website1), zoom1) + XCTAssertNil(model.zoomPerWebsite(url: website2)) + XCTAssertEqual(model.zoomPerWebsite(url: website3), zoom3) + XCTAssertNil(model.zoomPerWebsite(url: website4)) + XCTAssertEqual(persister.zoomPerWebsite[domain1], zoom1.rawValue) + XCTAssertNil(persister.zoomPerWebsite[domain2]) + XCTAssertEqual(persister.zoomPerWebsite[domain3], zoom3.rawValue) + XCTAssertNil(persister.zoomPerWebsite[domain4]) + } + + func test_whenBurningZoomLevelsPerSites_thenZoomLevelOfTheSiteIsNotRetained() { + // GIVEN + let expectation = XCTestExpectation() + UserDefaultsWrapper.clearAll() + let persister = AccessibilityPreferencesUserDefaultsPersistor() + let model = AccessibilityPreferences(persistor: persister) + model.updateZoomPerWebsite(zoomLevel: zoom1, url: website1) + model.updateZoomPerWebsite(zoomLevel: zoom2, url: website2) + model.updateZoomPerWebsite(zoomLevel: zoom3, url: website3) + model.updateZoomPerWebsite(zoomLevel: zoom4, url: website4) + model.zoomPerWebsiteUpdatedSubject + .receive(on: DispatchQueue.main) + .sink { _ in + expectation.fulfill() + }.store(in: &cancellables) + + // WHEN + model.burnZoomLevel(of: [domain1, domain4]) + wait(for: [expectation], timeout: 5) + + // THEN + XCTAssertNil(model.zoomPerWebsite(url: website1)) + XCTAssertEqual(model.zoomPerWebsite(url: website2), zoom2) + XCTAssertEqual(model.zoomPerWebsite(url: website3), zoom3) + XCTAssertNil(model.zoomPerWebsite(url: website4)) + XCTAssertNil(persister.zoomPerWebsite[domain1]) + XCTAssertEqual(persister.zoomPerWebsite[domain2], zoom2.rawValue) + XCTAssertEqual(persister.zoomPerWebsite[domain3], zoom3.rawValue) + XCTAssertNil(persister.zoomPerWebsite[domain4]) + } + +} + +class MockAccessibilityPreferencesPersistor: AccessibilityPreferencesPersistor { + var zoomPerWebsite: [String: CGFloat] = [:] + var defaultPageZoom: CGFloat = DefaultZoomValue.percent100.rawValue +} + +class MockFireproofDomains: FireproofDomains { + init(domains: [String]) { + super.init(store: FireproofDomainsStoreMock()) + for domain in domains { + super.add(domain: domain) + } + } } diff --git a/UnitTests/Tab/ViewModel/TabViewModelTests.swift b/UnitTests/Tab/ViewModel/TabViewModelTests.swift index d06378f00f..c2e83d6fbb 100644 --- a/UnitTests/Tab/ViewModel/TabViewModelTests.swift +++ b/UnitTests/Tab/ViewModel/TabViewModelTests.swift @@ -242,19 +242,21 @@ final class TabViewModelTests: XCTestCase { } @MainActor - func testWhenAppearancePreferencesZoomLevelIsSetThenTabsWebViewZoomLevelIsUpdated() { + func testWhenPreferencesDefaultZoomLevelIsSetThenTabsWebViewZoomLevelIsUpdated() { UserDefaultsWrapper.clearAll() let tabVM = TabViewModel(tab: Tab()) - let randomZoomLevel = DefaultZoomValue.allCases.randomElement()! + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! AccessibilityPreferences.shared.defaultPageZoom = randomZoomLevel XCTAssertEqual(tabVM.tab.webView.zoomLevel, randomZoomLevel) } @MainActor - func testWhenAppearancePreferencesZoomLevelIsSetAndANewTabIsOpenThenItsWebViewHasTheLatestValueOfZoomLevel() { + func testWhenPreferencesDefaultZoomLevelIsSetAndANewTabIsOpenThenItsWebViewHasTheLatestValueOfZoomLevel() { UserDefaultsWrapper.clearAll() - let randomZoomLevel = DefaultZoomValue.allCases.randomElement()! + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! AccessibilityPreferences.shared.defaultPageZoom = randomZoomLevel let tabVM = TabViewModel(tab: Tab(), appearancePreferences: AppearancePreferences()) @@ -262,6 +264,251 @@ final class TabViewModelTests: XCTestCase { XCTAssertEqual(tabVM.tab.webView.zoomLevel, randomZoomLevel) } + @MainActor + func test_WhenPreferencesDefaultZoomLevelIsSet_AndThereIsAZoomLevelForWebsite_ThenTabsWebViewZoomLevelIsNotUpdated() { + // GIVEN + UserDefaultsWrapper.clearAll() + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + var tab = Tab(url: url) + var tabVM = TabViewModel(tab: tab) + + // WHEN + AccessibilityPreferences.shared.defaultPageZoom = .percent50 + tab = Tab(url: url) + tabVM = TabViewModel(tab: tab) + + // THEN + XCTAssertEqual(tabVM.tab.webView.zoomLevel, randomZoomLevel) + } + + @MainActor + func test_WhenPreferencesDefaultZoomLevelIsSet_AndThereIsAZoomLevelForWebsite_AndIsFireWindow_ThenTabsWebViewZoomLevelIsNotUpdated() { + // GIVEN + UserDefaultsWrapper.clearAll() + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + var tab = Tab(url: url) + var tabVM = TabViewModel(tab: tab) + + // WHEN + AccessibilityPreferences.shared.defaultPageZoom = .percent50 + let burnerTab = Tab(content: .url(url, credential: nil, source: .ui), burnerMode: BurnerMode(isBurner: true)) + tabVM = TabViewModel(tab: burnerTab) + + // THEN + XCTAssertEqual(tabVM.tab.webView.zoomLevel, AccessibilityPreferences.shared.defaultPageZoom) + } + + @MainActor + func test_WhenPreferencesZoomPerWebsiteLevelIsSet_AndANewTabIsOpen_ThenItsWebViewHasTheLatestValueOfZoomLevel() { + // GIVEN + UserDefaultsWrapper.clearAll() + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + + // WHEN + let tab = Tab(url: url) + let tabVM = TabViewModel(tab: tab, appearancePreferences: AppearancePreferences()) + + // THEN + XCTAssertEqual(tabVM.tab.webView.zoomLevel, randomZoomLevel) + } + + @MainActor + func test_WhenPreferencesZoomPerWebsiteLevelIsSet_AndANewBurnerTabIsOpen_ThenItsWebViewHasTheDefaultZoomLevel() { + // GIVEN + UserDefaultsWrapper.clearAll() + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + + // WHEN + let burnerTab = Tab(content: .url(url, credential: nil, source: .ui), burnerMode: BurnerMode(isBurner: true)) + let tabVM = TabViewModel(tab: burnerTab, appearancePreferences: AppearancePreferences()) + + // THEN + XCTAssertEqual(tabVM.tab.webView.zoomLevel, AccessibilityPreferences.shared.defaultPageZoom) + } + + @MainActor + func test_WhenPreferencesZoomPerWebsiteLevelIsSet_ThenTabsWebViewZoomLevelIsUpdated() async { + // GIVEN + UserDefaultsWrapper.clearAll() + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + let tab = Tab(url: url) + let tabVM = TabViewModel(tab: tab) + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + + // WHEN + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + + // THEN + await MainActor.run { + XCTAssertEqual(tabVM.tab.webView.zoomLevel, randomZoomLevel, "Tab's web view zoom level was not updated as expected.") + } + } + + @MainActor + func test_WhenPreferencesZoomPerWebsiteLevelIsSet_AndIsFireWindow_ThenTabsWebViewZoomLevelIsNot() async { + // GIVEN + UserDefaultsWrapper.clearAll() + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + let burnerTab = Tab(content: .url(url, credential: nil, source: .ui), burnerMode: BurnerMode(isBurner: true)) + let tabVM = TabViewModel(tab: burnerTab) + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + + // WHEN + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + + // THEN + await MainActor.run { + XCTAssertEqual(tabVM.tab.webView.zoomLevel, AccessibilityPreferences.shared.defaultPageZoom) + } + } + + @MainActor + func test_WhenZoomWasSetIsCalled_ThenAppearancePreferencesPerWebsiteZoomIsSet() { + // GIVEN + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + UserDefaultsWrapper.clearAll() + let tab = Tab(url: url) + let tabVM = TabViewModel(tab: tab) + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom} + let randomZoomLevel = filteredCases.randomElement()! + + // WHEN + tabVM.zoomWasSet(to: randomZoomLevel) + + // THEN + XCTAssertEqual(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL), randomZoomLevel) + } + + @MainActor + func test_WhenZoomWasSetIsCalled_AndIsFireWindow_ThenAppearancePreferencesPerWebsiteZoomIsNotSet() { + // GIVEN + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: AccessibilityPreferences.shared.defaultPageZoom, url: hostURL) + UserDefaultsWrapper.clearAll() + let burnerTab = Tab(content: .url(url, credential: nil, source: .ui), burnerMode: BurnerMode(isBurner: true)) + let tabVM = TabViewModel(tab: burnerTab) + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom} + let randomZoomLevel = filteredCases.randomElement()! + + // WHEN + print(randomZoomLevel) + tabVM.zoomWasSet(to: randomZoomLevel) + + // THEN + XCTAssertEqual(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL), nil) + } + + @MainActor + func test_WhenWebViewResetZoomLevelForASite_ThenNoZoomSavedForTheSite() { + // GIVEN + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + UserDefaultsWrapper.clearAll() + let filteredCases = DefaultZoomValue.allCases.filter { $0 != AccessibilityPreferences.shared.defaultPageZoom } + let randomZoomLevel = filteredCases.randomElement()! + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + let tab = Tab(url: url) + let tabView = TabViewModel(tab: tab) + + // WHEN + tabView.tab.webView.resetZoomLevel() + + // THEN + XCTAssertEqual(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL), nil) + } + + @MainActor + func test_WhenWebViewZoomInForASite_ThenNewZoomSavedForTheSite() async { + // GIVEN + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + UserDefaultsWrapper.clearAll() + let (randomZoomLevel, nextZoomLevel, _) = randomLevelAndAdjacent() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, defer: false) + window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + let tab = Tab(url: url) + window.contentView?.addSubview(tab.webView) + tab.webView.frame = window.contentView!.bounds + window.makeKeyAndOrderFront(nil) + let tabView = TabViewModel(tab: tab) + + // WHEN + tabView.tab.webView.zoomIn() + + // THEN + if nextZoomLevel == AccessibilityPreferences.shared.defaultPageZoom { + XCTAssertNil(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL)) + } else { + XCTAssertEqual(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL), nextZoomLevel) + } + } + + @MainActor + func test_WhenWebViewZoomOutForASite_ThenNewZoomSavedForTheSite() async { + // GIVEN + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + UserDefaultsWrapper.clearAll() + let (randomZoomLevel, _, previousZoomLevel) = randomLevelAndAdjacent() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, defer: false) + window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + AccessibilityPreferences.shared.updateZoomPerWebsite(zoomLevel: randomZoomLevel, url: hostURL) + let tab = Tab(url: url) + window.contentView?.addSubview(tab.webView) + tab.webView.frame = window.contentView!.bounds + window.makeKeyAndOrderFront(nil) + let tabView = TabViewModel(tab: tab) + + // WHEN + tabView.tab.webView.zoomOut() + + // THEN + if previousZoomLevel == AccessibilityPreferences.shared.defaultPageZoom { + XCTAssertNil(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL)) + } else { + XCTAssertEqual(AccessibilityPreferences.shared.zoomPerWebsite(url: hostURL), previousZoomLevel) + } + } + + private func randomLevelAndAdjacent() -> (randomLevel: DefaultZoomValue, nextLevel: DefaultZoomValue, previousLevel: DefaultZoomValue) { + let allCases = DefaultZoomValue.allCases + + let selectableRange = 1..<(allCases.count - 1) + let randomIndex = selectableRange.randomElement()! + let randomLevel = allCases[randomIndex] + + let nextLevel = allCases[randomIndex + 1] + let previousLevel = allCases[randomIndex - 1] + + return (randomLevel, nextLevel, previousLevel) + } } extension TabViewModel { diff --git a/UnitTests/Tab/ViewModel/ZoomPopoverViewModelTests.swift b/UnitTests/Tab/ViewModel/ZoomPopoverViewModelTests.swift new file mode 100644 index 0000000000..b9f4adaf74 --- /dev/null +++ b/UnitTests/Tab/ViewModel/ZoomPopoverViewModelTests.swift @@ -0,0 +1,99 @@ +// +// ZoomPopoverViewModelTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class ZoomPopoverViewModelTests: XCTestCase { + + var tabVM: TabViewModel! + var zoomPopover: ZoomPopoverViewModel! + var accessibilityPreferences: AccessibilityPreferences! + let url = URL(string: "https://app.asana.com/0/1")! + let hostURL = "https://app.asana.com/" + + @MainActor + override func setUp() { + UserDefaultsWrapper.clearAll() + let tab = Tab(url: url) + tabVM = TabViewModel(tab: tab) + zoomPopover = ZoomPopoverViewModel(tabViewModel: tabVM) + let window = NSWindow() + window.contentView = tabVM.tab.webView + } + + @MainActor + func test_WhenZoomInFromPopover_ThenWebViewIsZoomedIn() async { + var increasableDefaultValue = DefaultZoomValue.allCases + increasableDefaultValue.removeLast() + let randomZoomLevel = increasableDefaultValue.randomElement()! + tabVM.tab.webView.zoomLevel = randomZoomLevel + + zoomPopover.zoomIn() + + await MainActor.run { + XCTAssertEqual(randomZoomLevel.index + 1, tabVM.tab.webView.zoomLevel.index) + XCTAssertEqual(randomZoomLevel.index + 1, zoomPopover.zoomLevel.index) + } + } + + @MainActor + func test_WhenZoomOutFromPopover_ThenWebViewIsZoomedOut() async { + var decreasableDefaultValue = DefaultZoomValue.allCases + decreasableDefaultValue.removeFirst() + let randomZoomLevel = decreasableDefaultValue.randomElement()! + tabVM.tab.webView.zoomLevel = randomZoomLevel + + zoomPopover.zoomOut() + + await MainActor.run { + XCTAssertEqual(randomZoomLevel.index - 1, tabVM.tab.webView.zoomLevel.index) + XCTAssertEqual(randomZoomLevel.index - 1, zoomPopover.zoomLevel.index) + } + } + + @MainActor + func test_WhenResetZoomFromPopover_ThenWebViewIsReset() async { + let randomZoomLevel = DefaultZoomValue.allCases.randomElement()! + tabVM.tab.webView.defaultZoomValue = .percent100 + tabVM.tab.webView.zoomLevel = randomZoomLevel + + zoomPopover.reset() + + XCTAssertEqual(.percent100, tabVM.tab.webView.zoomLevel) + await MainActor.run { + XCTAssertEqual(.percent100, zoomPopover.zoomLevel) + } + } + + @MainActor + func test_WhenZoomValueIsSetInTab_ThenPopoverZoomLevelUpdated() async { + let expectation = XCTestExpectation() + zoomPopover = ZoomPopoverViewModel(tabViewModel: tabVM) + let randomZoomLevel = DefaultZoomValue.allCases.randomElement()! + + Task { + tabVM.zoomWasSet(to: randomZoomLevel) + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 5.0) + XCTAssertEqual(zoomPopover.zoomLevel, randomZoomLevel) + } + +} diff --git a/UnitTests/Tab/WebViewTests.swift b/UnitTests/Tab/WebViewTests.swift index aedaeccc0d..836029b704 100644 --- a/UnitTests/Tab/WebViewTests.swift +++ b/UnitTests/Tab/WebViewTests.swift @@ -26,14 +26,17 @@ final class WebViewTests: XCTestCase { let window = NSWindow() var webView: WebView! + var capturingZoomLevelDelegate: CapturingZoomLevelDelegate! override func setUp() { webView = .init(frame: .zero) window.contentView?.addSubview(webView) + capturingZoomLevelDelegate = CapturingZoomLevelDelegate() } override func tearDown() { webView = nil + capturingZoomLevelDelegate = nil } func testInitialZoomLevelAndMagnification() { @@ -162,4 +165,55 @@ final class WebViewTests: XCTestCase { XCTAssertEqual(tabVM.tab.webView.magnification, 1) } + + func test_WhenZoomingIn_ThenZoomDelegateZoomWasSetIsCalled() { + // GIVEN + var increasableDefaultValue = DefaultZoomValue.allCases + increasableDefaultValue.removeLast() + let randomZoomLevel = increasableDefaultValue.randomElement()! + webView.zoomLevel = randomZoomLevel + webView.zoomLevelDelegate = capturingZoomLevelDelegate + + // WHEN + webView.zoomIn() + + // THEN + XCTAssertEqual(capturingZoomLevelDelegate.setLevel, DefaultZoomValue.allCases[randomZoomLevel.index + 1]) + } + + func test_WhenZoomingOut_ThenZoomDelegateZoomWasSetIsCalled() { + // GIVEN + var decreasableDefaultValue = DefaultZoomValue.allCases + decreasableDefaultValue.removeFirst() + let randomZoomLevel = decreasableDefaultValue.randomElement()! + webView.zoomLevel = randomZoomLevel + webView.zoomLevelDelegate = capturingZoomLevelDelegate + + // WHEN + webView.zoomOut() + + // THEN + XCTAssertEqual(capturingZoomLevelDelegate.setLevel, DefaultZoomValue.allCases[randomZoomLevel.index - 1]) + } + + func testWhenResettingZoomThenZoomDelegateZoomWasSetIsCalled() { + // GIVEN + let randomZoomLevel = DefaultZoomValue.allCases.randomElement()! + webView.zoomLevel = randomZoomLevel + webView.zoomLevelDelegate = capturingZoomLevelDelegate + + // WHEN + webView.resetZoomLevel() + + // THEN + XCTAssertEqual(capturingZoomLevelDelegate.setLevel, .percent100) + } } + +class CapturingZoomLevelDelegate: WebViewZoomLevelDelegate { + var setLevel: DefaultZoomValue? + + func zoomWasSet(to level: DefaultZoomValue) { + setLevel = level + } + } diff --git a/UnitTests/TabBar/Model/TabCollectionTests.swift b/UnitTests/TabBar/Model/TabCollectionTests.swift index 983091b856..7d9b94a790 100644 --- a/UnitTests/TabBar/Model/TabCollectionTests.swift +++ b/UnitTests/TabBar/Model/TabCollectionTests.swift @@ -184,6 +184,11 @@ extension Tab { convenience override init() { self.init(content: .newtab) } + + @MainActor + convenience init(url: URL) { + self.init(content: .url(url, credential: nil, source: .userEntered(url.absoluteString, downloadRequested: false))) + } } class HistoryTabExtensionMock: TabExtension, HistoryExtensionProtocol {