diff --git a/.bundle/config b/.bundle/config index da970cb8c12e..c6d8958ffd24 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,3 +1,4 @@ --- BUNDLE_PATH: "vendor/bundle" +BUNDLE_JOBS: "16" BUNDLE_WITHOUT: "screenshots" diff --git a/Gemfile.lock b/Gemfile.lock index ba109aab68e2..fcd681c4ad37 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,22 @@ +GIT + remote: git@github.com:wordpress-mobile/release-toolkit + revision: eccf5a273276e976cd42c66590af087d1654037f + branch: develop + specs: + fastlane-plugin-wpmreleasetoolkit (2.1.0) + activesupport (~> 5) + bigdecimal (~> 1.4) + chroma (= 0.2.0) + diffy (~> 3.3) + git (~> 1.3) + jsonlint (~> 0.3) + nokogiri (~> 1.11) + octokit (~> 4.18) + parallel (~> 1.14) + progress_bar (~> 1.3) + rake (>= 12.3, < 14.0) + rake-compiler (~> 1.0) + GEM remote: https://rubygems.org/ specs: @@ -165,19 +184,6 @@ GEM trainer xcodeproj xctest_list (>= 1.2.1) - fastlane-plugin-wpmreleasetoolkit (2.1.0) - activesupport (~> 5) - bigdecimal (~> 1.4) - chroma (= 0.2.0) - diffy (~> 3.3) - git (~> 1.3) - jsonlint (~> 0.3) - nokogiri (~> 1.11) - octokit (~> 4.18) - parallel (~> 1.14) - progress_bar (~> 1.3) - rake (>= 12.3, < 14.0) - rake-compiler (~> 1.0) ffi (1.15.0) fourflusher (2.3.1) fuzzy_match (2.0.4) @@ -226,7 +232,7 @@ GEM http-cookie (1.0.4) domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.8.10) + i18n (1.8.11) concurrent-ruby (~> 1.0) jmespath (1.4.0) json (2.5.1) @@ -340,11 +346,11 @@ DEPENDENCIES fastlane-plugin-appcenter (~> 1.8) fastlane-plugin-sentry fastlane-plugin-test_center - fastlane-plugin-wpmreleasetoolkit (~> 2.1) + fastlane-plugin-wpmreleasetoolkit! octokit (~> 4.0) rake rmagick (~> 3.2.0) xcpretty-travis-formatter BUNDLED WITH - 2.2.27 + 2.2.31 diff --git a/Podfile b/Podfile index 18d09a99e976..a3f70f6943c0 100644 --- a/Podfile +++ b/Podfile @@ -20,7 +20,7 @@ workspace 'WordPress.xcworkspace' ## =================================== ## def wordpress_shared - pod 'WordPressShared', '~> 1.16.2' + pod 'WordPressShared', '~> 1.17.0' #pod 'WordPressShared', :git => 'https://github.com/wordpress-mobile/WordPress-iOS-Shared.git', :tag => '' #pod 'WordPressShared', :git => 'https://github.com/wordpress-mobile/WordPress-iOS-Shared.git', :branch => '' #pod 'WordPressShared', :git => 'https://github.com/wordpress-mobile/WordPress-iOS-Shared.git', :commit => '' @@ -47,7 +47,7 @@ def wordpress_ui end def wordpress_kit - pod 'WordPressKit', '~> 4.43.0' + pod 'WordPressKit', '~> 4.44.0' # pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :tag => '' # pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :branch => '' # pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :commit => '' @@ -166,7 +166,7 @@ abstract_target 'Apps' do ## Gutenberg (React Native) ## ===================== ## - gutenberg :tag => 'v1.66.0' + gutenberg :tag => 'v1.67.0' ## Third party libraries ## ===================== diff --git a/Podfile.lock b/Podfile.lock index 12eab5f0ff27..8e4586f2be6b 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -62,7 +62,7 @@ PODS: - AppAuth/Core (~> 1.4) - GTMSessionFetcher/Core (~> 1.5) - GTMSessionFetcher/Core (1.7.0) - - Gutenberg (1.66.0): + - Gutenberg (1.67.0): - React (= 0.64.0) - React-CoreModules (= 0.64.0) - React-RCTImage (= 0.64.0) @@ -427,7 +427,7 @@ PODS: - React-Core - RNSVG (9.13.7-wp-1): - React-Core - - RNTAztecView (1.66.0): + - RNTAztecView (1.67.0): - React-Core - WordPress-Aztec-iOS (~> 1.19.5) - Sentry (6.2.1): @@ -452,7 +452,7 @@ PODS: - WordPressKit (~> 4.18-beta) - WordPressShared (~> 1.12-beta) - WordPressUI (~> 1.7-beta) - - WordPressKit (4.43.0): + - WordPressKit (4.44.0): - Alamofire (~> 4.8.0) - CocoaLumberjack (~> 3.4) - NSObject-SafeExpectations (= 0.0.4) @@ -460,7 +460,7 @@ PODS: - WordPressShared (~> 1.15-beta) - wpxmlrpc (~> 0.9) - WordPressMocks (0.0.15) - - WordPressShared (1.16.2): + - WordPressShared (1.17.0): - CocoaLumberjack (~> 3.4) - FormatterKit/TimeIntervalFormatter (~> 1.8) - WordPressUI (1.12.2) @@ -490,18 +490,18 @@ DEPENDENCIES: - AppCenter (~> 4.1) - AppCenter/Distribute (~> 4.1) - Automattic-Tracks-iOS (~> 0.9.1) - - BVLinearGradient (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/BVLinearGradient.podspec.json`) + - BVLinearGradient (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/BVLinearGradient.podspec.json`) - Charts (~> 3.2.2) - CocoaLumberjack (~> 3.0) - CropViewController (= 2.5.3) - Down (~> 0.6.6) - - FBLazyVector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/FBLazyVector.podspec.json`) - - FBReactNativeSpec (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json`) + - FBLazyVector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/FBLazyVector.podspec.json`) + - FBReactNativeSpec (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json`) - FSInteractiveMap (from `https://github.com/wordpress-mobile/FSInteractiveMap.git`, tag `0.2.0`) - Gifu (= 3.2.0) - - glog (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/glog.podspec.json`) + - glog (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/glog.podspec.json`) - Gridicons (~> 1.1.0) - - Gutenberg (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.66.0`) + - Gutenberg (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.67.0`) - JTAppleCalendar (~> 8.0.2) - Kanvas (~> 1.2.7) - MediaEditor (~> 1.2.1) @@ -511,61 +511,60 @@ DEPENDENCIES: - "NSURL+IDN (~> 0.4)" - OCMock (~> 3.4.3) - OHHTTPStubs/Swift (~> 9.1.0) - - RCT-Folly (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RCT-Folly.podspec.json`) - - RCTRequired (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RCTRequired.podspec.json`) - - RCTTypeSafety (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RCTTypeSafety.podspec.json`) + - RCT-Folly (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RCT-Folly.podspec.json`) + - RCTRequired (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RCTRequired.podspec.json`) + - RCTTypeSafety (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RCTTypeSafety.podspec.json`) - Reachability (= 3.2) - - React (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React.podspec.json`) - - React-callinvoker (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-callinvoker.podspec.json`) - - React-Core (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-Core.podspec.json`) - - React-CoreModules (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-CoreModules.podspec.json`) - - React-cxxreact (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-cxxreact.podspec.json`) - - React-jsi (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-jsi.podspec.json`) - - React-jsiexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-jsiexecutor.podspec.json`) - - React-jsinspector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-jsinspector.podspec.json`) - - react-native-blur (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-blur.podspec.json`) - - react-native-get-random-values (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-get-random-values.podspec.json`) - - react-native-keyboard-aware-scroll-view (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json`) - - react-native-safe-area (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-safe-area.podspec.json`) - - react-native-safe-area-context (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-safe-area-context.podspec.json`) - - react-native-slider (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-slider.podspec.json`) - - react-native-video (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-video.podspec.json`) - - react-native-webview (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-webview.podspec.json`) - - React-perflogger (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-perflogger.podspec.json`) - - React-RCTActionSheet (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTActionSheet.podspec.json`) - - React-RCTAnimation (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTAnimation.podspec.json`) - - React-RCTBlob (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTBlob.podspec.json`) - - React-RCTImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTImage.podspec.json`) - - React-RCTLinking (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTLinking.podspec.json`) - - React-RCTNetwork (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTNetwork.podspec.json`) - - React-RCTSettings (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTSettings.podspec.json`) - - React-RCTText (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTText.podspec.json`) - - React-RCTVibration (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTVibration.podspec.json`) - - React-runtimeexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-runtimeexecutor.podspec.json`) - - ReactCommon (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/ReactCommon.podspec.json`) - - RNCMaskedView (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNCMaskedView.podspec.json`) - - RNGestureHandler (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNGestureHandler.podspec.json`) - - RNReanimated (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNReanimated.podspec.json`) - - RNScreens (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNScreens.podspec.json`) - - RNSVG (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNSVG.podspec.json`) - - RNTAztecView (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.66.0`) + - React (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React.podspec.json`) + - React-callinvoker (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-callinvoker.podspec.json`) + - React-Core (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-Core.podspec.json`) + - React-CoreModules (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-CoreModules.podspec.json`) + - React-cxxreact (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-cxxreact.podspec.json`) + - React-jsi (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-jsi.podspec.json`) + - React-jsiexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-jsiexecutor.podspec.json`) + - React-jsinspector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-jsinspector.podspec.json`) + - react-native-blur (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-blur.podspec.json`) + - react-native-get-random-values (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-get-random-values.podspec.json`) + - react-native-keyboard-aware-scroll-view (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json`) + - react-native-safe-area (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-safe-area.podspec.json`) + - react-native-safe-area-context (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-safe-area-context.podspec.json`) + - react-native-slider (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-slider.podspec.json`) + - react-native-video (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-video.podspec.json`) + - react-native-webview (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-webview.podspec.json`) + - React-perflogger (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-perflogger.podspec.json`) + - React-RCTActionSheet (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTActionSheet.podspec.json`) + - React-RCTAnimation (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTAnimation.podspec.json`) + - React-RCTBlob (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTBlob.podspec.json`) + - React-RCTImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTImage.podspec.json`) + - React-RCTLinking (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTLinking.podspec.json`) + - React-RCTNetwork (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTNetwork.podspec.json`) + - React-RCTSettings (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTSettings.podspec.json`) + - React-RCTText (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTText.podspec.json`) + - React-RCTVibration (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTVibration.podspec.json`) + - React-runtimeexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-runtimeexecutor.podspec.json`) + - ReactCommon (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/ReactCommon.podspec.json`) + - RNCMaskedView (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNCMaskedView.podspec.json`) + - RNGestureHandler (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNGestureHandler.podspec.json`) + - RNReanimated (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNReanimated.podspec.json`) + - RNScreens (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNScreens.podspec.json`) + - RNSVG (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNSVG.podspec.json`) + - RNTAztecView (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.67.0`) - Starscream (= 3.0.6) - SVProgressHUD (= 2.2.5) - WordPress-Editor-iOS (~> 1.19.5) - WordPressAuthenticator (~> 1.42.1) - - WordPressKit (~> 4.43.0) + - WordPressKit (~> 4.44.0) - WordPressMocks (~> 0.0.15) - - WordPressShared (~> 1.16.2) + - WordPressShared (~> 1.17.0) - WordPressUI (~> 1.12.2) - WPMediaPicker (~> 1.7.2) - - Yoga (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/Yoga.podspec.json`) + - Yoga (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/Yoga.podspec.json`) - ZendeskSupportSDK (= 5.3.0) - ZIPFoundation (~> 0.9.8) SPEC REPOS: https://github.com/wordpress-mobile/cocoapods-specs.git: - WordPressAuthenticator - - WordPressKit - WordPressUI trunk: - 1PasswordExtension @@ -606,6 +605,7 @@ SPEC REPOS: - UIDeviceIdentifier - WordPress-Aztec-iOS - WordPress-Editor-iOS + - WordPressKit - WordPressMocks - WordPressShared - WPMediaPicker @@ -621,98 +621,98 @@ SPEC REPOS: EXTERNAL SOURCES: BVLinearGradient: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/BVLinearGradient.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/BVLinearGradient.podspec.json FBLazyVector: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/FBLazyVector.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/FBLazyVector.podspec.json FBReactNativeSpec: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json FSInteractiveMap: :git: https://github.com/wordpress-mobile/FSInteractiveMap.git :tag: 0.2.0 glog: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/glog.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/glog.podspec.json Gutenberg: :git: https://github.com/wordpress-mobile/gutenberg-mobile.git :submodules: true - :tag: v1.66.0 + :tag: v1.67.0 RCT-Folly: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RCT-Folly.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RCT-Folly.podspec.json RCTRequired: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RCTRequired.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RCTRequired.podspec.json RCTTypeSafety: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RCTTypeSafety.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RCTTypeSafety.podspec.json React: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React.podspec.json React-callinvoker: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-callinvoker.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-callinvoker.podspec.json React-Core: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-Core.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-Core.podspec.json React-CoreModules: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-CoreModules.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-CoreModules.podspec.json React-cxxreact: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-cxxreact.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-cxxreact.podspec.json React-jsi: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-jsi.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-jsi.podspec.json React-jsiexecutor: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-jsiexecutor.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-jsiexecutor.podspec.json React-jsinspector: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-jsinspector.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-jsinspector.podspec.json react-native-blur: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-blur.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-blur.podspec.json react-native-get-random-values: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-get-random-values.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-get-random-values.podspec.json react-native-keyboard-aware-scroll-view: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json react-native-safe-area: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-safe-area.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-safe-area.podspec.json react-native-safe-area-context: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-safe-area-context.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-safe-area-context.podspec.json react-native-slider: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-slider.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-slider.podspec.json react-native-video: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-video.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-video.podspec.json react-native-webview: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/react-native-webview.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/react-native-webview.podspec.json React-perflogger: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-perflogger.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-perflogger.podspec.json React-RCTActionSheet: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTActionSheet.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTActionSheet.podspec.json React-RCTAnimation: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTAnimation.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTAnimation.podspec.json React-RCTBlob: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTBlob.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTBlob.podspec.json React-RCTImage: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTImage.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTImage.podspec.json React-RCTLinking: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTLinking.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTLinking.podspec.json React-RCTNetwork: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTNetwork.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTNetwork.podspec.json React-RCTSettings: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTSettings.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTSettings.podspec.json React-RCTText: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTText.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTText.podspec.json React-RCTVibration: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-RCTVibration.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-RCTVibration.podspec.json React-runtimeexecutor: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/React-runtimeexecutor.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/React-runtimeexecutor.podspec.json ReactCommon: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/ReactCommon.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/ReactCommon.podspec.json RNCMaskedView: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNCMaskedView.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNCMaskedView.podspec.json RNGestureHandler: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNGestureHandler.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNGestureHandler.podspec.json RNReanimated: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNReanimated.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNReanimated.podspec.json RNScreens: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNScreens.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNScreens.podspec.json RNSVG: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/RNSVG.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/RNSVG.podspec.json RNTAztecView: :git: https://github.com/wordpress-mobile/gutenberg-mobile.git :submodules: true - :tag: v1.66.0 + :tag: v1.67.0 Yoga: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.66.0/third-party-podspecs/Yoga.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.67.0/third-party-podspecs/Yoga.podspec.json CHECKOUT OPTIONS: FSInteractiveMap: @@ -721,11 +721,11 @@ CHECKOUT OPTIONS: Gutenberg: :git: https://github.com/wordpress-mobile/gutenberg-mobile.git :submodules: true - :tag: v1.66.0 + :tag: v1.67.0 RNTAztecView: :git: https://github.com/wordpress-mobile/gutenberg-mobile.git :submodules: true - :tag: v1.66.0 + :tag: v1.67.0 SPEC CHECKSUMS: 1PasswordExtension: f97cc80ae58053c331b2b6dc8843ba7103b33794 @@ -753,7 +753,7 @@ SPEC CHECKSUMS: Gridicons: 17d660b97ce4231d582101b02f8280628b141c9a GTMAppAuth: ad5c2b70b9a8689e1a04033c9369c4915bfcbe89 GTMSessionFetcher: 43748f93435c2aa068b1cbe39655aaf600652e91 - Gutenberg: b7b9e6dfa18bf59497ec7f1a0f51d3cfeac54265 + Gutenberg: 51ce08213438819aaa37a0597e9e860292958ba4 JTAppleCalendar: 932cadea40b1051beab10f67843451d48ba16c99 Kanvas: 9eab00cc89669b38858d42d5f30c810876b31344 lottie-ios: 3a3758ef5a008e762faec9c9d50a39842f26d124 @@ -801,7 +801,7 @@ SPEC CHECKSUMS: RNReanimated: de5b2ed087548c7a97d42e00aaaabc60d09e5e1a RNScreens: 6eaae52cb2c8125df482417e9b18ac19e5cd4ff6 RNSVG: f1119936d843b227a357d64781ab2df398f8d6db - RNTAztecView: 493f254859e356ef4708969524c2b2f954d7e8d8 + RNTAztecView: e09cdc4fca1f7e9a9f72b89bbf228fb4e0dcb7c2 Sentry: 9b922b396b0e0bca8516a10e36b0ea3ebea5faf7 Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da Starscream: ef3ece99d765eeccb67de105bfa143f929026cf5 @@ -810,9 +810,9 @@ SPEC CHECKSUMS: WordPress-Aztec-iOS: af36d9cb86a0109b568f516874870e2801ba1bd9 WordPress-Editor-iOS: 446be349b94707c1a82a83d525b86dbcf18cf2c7 WordPressAuthenticator: 111793c08fa8e9d9a72aed5b33a094c91ff4fd82 - WordPressKit: ea1b285bae9156e387ddcbe2a7f919c0783a9b91 + WordPressKit: 9ba5691ebe42f7bee2d0032386c7c8ee27c20c32 WordPressMocks: 6b52b0764d9939408151367dd9c6e8a910877f4d - WordPressShared: 6f4d949aa3ec8c3b9c24f5aa601473f087badd24 + WordPressShared: a4b0308a6345d4dda20c8f7ad9317df4246b4a00 WordPressUI: c573f4b5c2e5d0ffcebe69ecf86ae75ab7b6ff4d WPMediaPicker: d5ae9a83cd5cc0e4de46bfc1c59120aa86658bc3 wpxmlrpc: bf55a43a7e710bd2a4fb8c02dfe83b1246f14f13 @@ -826,6 +826,6 @@ SPEC CHECKSUMS: ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba ZIPFoundation: e27423c004a5a1410c15933407747374e7c6cb6e -PODFILE CHECKSUM: 05f1d233304eca44dd7b86f86e966d786c29c86f +PODFILE CHECKSUM: 60352df76a9f4471bd7766984cd37ca07c5705ed COCOAPODS: 1.10.1 diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index fc3fc8f2d402..a9f3704e4cf4 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,5 +1,20 @@ +18.9 +----- + + 18.8 ----- +* [*] Added a new About screen, with links to rate the app, share it with others, visit our Twitter profile, view our other apps, and more. [https://github.com/orgs/wordpress-mobile/projects/107] +* [*] Editor: Show a compact notice when switching between HTML or Visual mode. [https://github.com/wordpress-mobile/WordPress-iOS/pull/17521] +* [*] Onboarding Improvements: Need a little help after login? We're here for you. We've made a few changes to the login flow that will make it easier for you to start managing your site or create a new one. [#17564] +* [***] Fixed crash where uploading image when offline crashes iOS app. [#17488] +* [***] Fixed crash that was sometimes triggered when deleting media. [#17559] +* [***] Fixes a crasher that was sometimes triggered when seeing the details for like notifications. [#17529] +* [**] Block editor: Add clipboard link suggestion to image block and button block. [https://github.com/WordPress/gutenberg/pull/35972] +* [*] Block editor: Embed block: Include link in block settings. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4189] +* [**] Block editor: Fix tab titles translation of inserter menu. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4248] +* [**] Block editor: Gallery block: When a gallery block is added, the media options are auto opened for v2 of the Gallery block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4277] +* [*] Block editor: Media & Text block: Fix an issue where the text font size would be bigger than expected in some cases. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4252] 18.7 ----- diff --git a/Scripts/localize.py b/Scripts/localize.py deleted file mode 100755 index b6abcef6c371..000000000000 --- a/Scripts/localize.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# This program is free software. It comes without any warranty, to -# the extent permitted by applicable law. You can redistribute it -# and/or modify it under the terms of the Do What The Fuck You Want -# To Public License, Version 2, as published by Sam Hocevar. See -# http://sam.zoy.org/wtfpl/COPYING for more details. -# -# Localize.py - Incremental localization on XCode projects -# João Moreno 2009 -# http://joaomoreno.com/ - -from sys import argv -from codecs import open -from re import compile -from copy import copy -import os - -re_translation = compile(r'^"(.+)" = "(.+)";$') -re_comment_single = compile(r'^/(/.*|\*.*\*/)$') -re_comment_start = compile(r'^/\*.*$') -re_comment_end = compile(r'^.*\*/$') - -def print_help(): - print u"""Usage: merge.py merged_file old_file new_file -Xcode localizable strings merger script. João Moreno 2009.""" - -class LocalizedString(): - def __init__(self, comments, translation): - self.comments, self.translation = comments, translation - self.key, self.value = re_translation.match(self.translation).groups() - - def __unicode__(self): - return u'%s%s\n' % (u''.join(self.comments), self.translation) - -class LocalizedFile(): - def __init__(self, fname=None, auto_read=False): - self.fname = fname - self.strings = [] - self.strings_d = {} - - if auto_read: - self.read_from_file(fname) - - def read_from_file(self, fname=None): - fname = self.fname if fname == None else fname - try: - f = open(fname, encoding='utf_16', mode='r') - except: - print 'File %s does not exist.' % fname - exit(-1) - - line = f.readline() - while line and line == u'\n': - line = f.readline() - - while line: - comments = [line] - - if not re_comment_single.match(line): - while line and not re_comment_end.match(line): - line = f.readline() - comments.append(line) - - line = f.readline() - if line and re_translation.match(line): - translation = line - else: - raise Exception('invalid file: %s' % line) - - line = f.readline() - while line and line == u'\n': - line = f.readline() - - string = LocalizedString(comments, translation) - self.strings.append(string) - self.strings_d[string.key] = string - - f.close() - - def save_to_file(self, fname=None): - fname = self.fname if fname == None else fname - try: - f = open(fname, encoding='utf_16', mode='w') - except: - print 'Couldn\'t open file %s.' % fname - exit(-1) - - for string in self.strings: - f.write(string.__unicode__()) - - f.close() - - def merge_with(self, new): - merged = LocalizedFile() - - for string in new.strings: - if self.strings_d.has_key(string.key): - new_string = copy(self.strings_d[string.key]) - new_string.comments = string.comments - string = new_string - - merged.strings.append(string) - merged.strings_d[string.key] = string - - return merged - -def merge(merged_fname, old_fname, new_fname): - try: - old = LocalizedFile(old_fname, auto_read=True) - new = LocalizedFile(new_fname, auto_read=True) - except Exception as e: - print 'Error: input files have invalid format. old: %s, new: %s' % (old_fname, new_fname) - print e - - merged = old.merge_with(new) - - merged.save_to_file(merged_fname) - -STRINGS_FILE = 'Localizable.strings' - -def localize(path, language, include_pods_and_frameworks): - if "Scripts" in path: - print "Must run script from the root folder" - quit() - - os.chdir(path) - language = os.path.join(path, language) - - original = merged = language + os.path.sep + STRINGS_FILE - old = original + '.old' - new = original + '.new' - - # TODO: This is super ugly, we have to come up with a better way of doing it - if include_pods_and_frameworks: - find_cmd = 'find . ../Pods/WordPress* ../Pods/WPMediaPicker ../WordPressShared/WordPressShared ../Pods/Gutenberg -name "*.m" -o -name "*.swift" | grep -v Vendor | grep -v ./WordPressTest/I18n.swift | grep -v ./WordPressStatsWidgets/Views/Localization/LocalizedStringKey+extension.swift | grep -v Secrets.swift' - else: - find_cmd = 'find . -name "*.m" -o -name "*.swift" | grep -v Vendor | grep -v ./WordPressTest/I18n.swift | grep -v ./WordPressStatsWidgets/Views/Localization/LocalizedStringKey+extension.swift | grep -v Secrets.swift' - filelist = os.popen(find_cmd).read().strip().split('\n') - filelist = '"{0}"'.format('" "'.join(filelist)) - - if os.path.isfile(original): - os.rename(original, old) - os.system('genstrings -q -o "%s" %s' % (language, filelist)) - os.rename(original, new) - merge(merged, old, new) - os.remove(new) - os.remove(old) - else: - os.system('genstrings -q -o "%s" %s' % (language, filelist)) - -if __name__ == '__main__': - basedir = os.getcwd() - localize(os.path.join(basedir, 'WordPress'), 'Resources/en.lproj', True) - localize(os.path.join(basedir, 'WordPress', 'WordPressTodayWidget'), 'Base.lproj', False) - localize(os.path.join(basedir, 'WordPress', 'WordPressShareExtension'), 'Base.lproj', False) - diff --git a/WordPress/Classes/Categories/NSObject+Helpers.h b/WordPress/Classes/Categories/NSObject+Helpers.h index 10df519e4a78..0fa81ebe6fc1 100644 --- a/WordPress/Classes/Categories/NSObject+Helpers.h +++ b/WordPress/Classes/Categories/NSObject+Helpers.h @@ -6,4 +6,5 @@ + (nonnull NSString *)classNameWithoutNamespaces; +- (void)debounce:(SEL)selector afterDelay:(NSTimeInterval)timeInterval; @end diff --git a/WordPress/Classes/Categories/NSObject+Helpers.m b/WordPress/Classes/Categories/NSObject+Helpers.m index bbb02b7e40f0..ca49efa20aea 100644 --- a/WordPress/Classes/Categories/NSObject+Helpers.m +++ b/WordPress/Classes/Categories/NSObject+Helpers.m @@ -11,4 +11,14 @@ + (NSString *)classNameWithoutNamespaces return [[NSStringFromClass(self) componentsSeparatedByString:@"."] lastObject]; } +- (void)debounce:(SEL)selector afterDelay:(NSTimeInterval)timeInterval +{ + __weak __typeof(self) weakSelf = self; + [NSObject cancelPreviousPerformRequestsWithTarget:weakSelf + selector:selector + object:nil]; + [weakSelf performSelector:selector + withObject:nil + afterDelay:timeInterval]; +} @end diff --git a/WordPress/Classes/Models/Blog+Lookup.swift b/WordPress/Classes/Models/Blog+Lookup.swift index f5932c3558cc..1da056a8b44a 100644 --- a/WordPress/Classes/Models/Blog+Lookup.swift +++ b/WordPress/Classes/Models/Blog+Lookup.swift @@ -45,4 +45,17 @@ public extension Blog { // assemble the predicate as in `NSPredicate("blogID == %@")` try? lookup(withID: id.int64Value, in: context) } + + /// Lookup a Blog by WP.ORG Credentials + /// + /// - Parameters: + /// - username: The username associated with the blog. + /// - xmlrpc: The xmlrpc URL address + /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`. + /// - Returns: The `Blog` object associated with the given `username` and `xmlrpc`, if it exists. + static func lookup(username: String, xmlrpc: String, in context: NSManagedObjectContext) -> Blog? { + let service = BlogService(managedObjectContext: context) + + return service.findBlog(withXmlrpc: xmlrpc, andUsername: username) + } } diff --git a/WordPress/Classes/Models/Comment+CoreDataClass.swift b/WordPress/Classes/Models/Comment+CoreDataClass.swift index 6d5b1ef83804..f3fbb1c1aa68 100644 --- a/WordPress/Classes/Models/Comment+CoreDataClass.swift +++ b/WordPress/Classes/Models/Comment+CoreDataClass.swift @@ -70,12 +70,42 @@ public class Comment: NSManagedObject { /// Convenience method to check if the current user can actually moderate. /// `canModerate` is only applicable when the site is dotcom-related (hosted or atomic). For self-hosted sites, default to true. func allowsModeration() -> Bool { + if let _ = post as? ReaderPost { + return canModerate + } + guard let blog = blog, (blog.isHostedAtWPcom || blog.isAtomic()) else { return true } return canModerate } + + func canReply() -> Bool { + if let readerPost = post as? ReaderPost { + return readerPost.commentsOpen && ReaderHelpers.isLoggedIn() + } + + return !isReadOnly() + } + + // NOTE: Comment Likes could be disabled, but the API doesn't have that info yet. Let's update this once it's available. + func canLike() -> Bool { + if let _ = post as? ReaderPost { + return ReaderHelpers.isLoggedIn() + } + + guard let blog = blog else { + // Disable likes feature for self-hosted sites. + return false + } + + return !isReadOnly() && blog.supports(.commentLikes) + } + + @objc func isTopLevelComment() -> Bool { + return depth == 0 + } } private extension Comment { diff --git a/WordPress/Classes/Services/CommentService.h b/WordPress/Classes/Services/CommentService.h index 1fbda41a1c76..8f8f00ec1bde 100644 --- a/WordPress/Classes/Services/CommentService.h +++ b/WordPress/Classes/Services/CommentService.h @@ -95,12 +95,25 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; success:(void (^)(void))success failure:(void (^)(NSError *error))failure; -// Sync a list of comments sorted by hierarchy +// Sync a list of comments sorted by hierarchy, fetched by page number. - (void)syncHierarchicalCommentsForPost:(ReaderPost *)post page:(NSUInteger)page - success:(void (^)(NSInteger count, BOOL hasMore))success + success:(void (^)(BOOL hasMore, NSNumber *totalComments))success failure:(void (^)(NSError *error))failure; +// Sync a list of comments sorted by hierarchy, restricted by the specified number of _top level_ comments. +// This method is intended to get a small number of comments. +// Therefore it is restricted to page 1 only. +- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post + topLevelComments:(NSUInteger)number + success:(void (^)(BOOL hasMore, NSNumber *totalComments))success + failure:(void (^)(NSError *error))failure; + +// Get the specified number of top level comments for the specified post. +// This method is intended to get a small number of comments. +// Therefore it is restricted to page 1 only. +- (NSArray *)topLevelComments:(NSUInteger)number forPost:(ReaderPost *)post; + // Counts and returns the number of full pages of hierarchcial comments synced for a post. // A partial set does not count toward the total number of pages. - (NSInteger)numberOfHierarchicalPagesSyncedforPost:(ReaderPost *)post; diff --git a/WordPress/Classes/Services/CommentService.m b/WordPress/Classes/Services/CommentService.m index 8c36f925aa39..a127dc059eb0 100644 --- a/WordPress/Classes/Services/CommentService.m +++ b/WordPress/Classes/Services/CommentService.m @@ -512,18 +512,47 @@ - (void)deleteComment:(Comment *)comment - (void)syncHierarchicalCommentsForPost:(ReaderPost *)post page:(NSUInteger)page - success:(void (^)(NSInteger count, BOOL hasMore))success + success:(void (^)(BOOL hasMore, NSNumber *totalComments))success + failure:(void (^)(NSError *error))failure +{ + [self syncHierarchicalCommentsForPost:post + page:page + topLevelComments:WPTopLevelHierarchicalCommentsPerPage + success:success + failure:failure]; +} + +- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post + topLevelComments:(NSUInteger)number + success:(void (^)(BOOL hasMore, NSNumber *totalComments))success + failure:(void (^)(NSError *error))failure +{ + [self syncHierarchicalCommentsForPost:post + page:1 + topLevelComments:number + success:success + failure:failure]; +} + +- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post + page:(NSUInteger)page + topLevelComments:(NSUInteger)number + success:(void (^)(BOOL hasMore, NSNumber *totalComments))success failure:(void (^)(NSError *error))failure { NSManagedObjectID *postObjectID = post.objectID; NSNumber *siteID = post.siteID; NSNumber *postID = post.postID; + + NSUInteger commentsPerPage = number ?: WPTopLevelHierarchicalCommentsPerPage; + NSUInteger pageNumber = page ?: 1; + [self.managedObjectContext performBlock:^{ CommentServiceRemoteREST *service = [self restRemoteForSite:siteID]; [service syncHierarchicalCommentsForPost:postID - page:page - number:WPTopLevelHierarchicalCommentsPerPage - success:^(NSArray *comments) { + page:pageNumber + number:commentsPerPage + success:^(NSArray *comments, NSNumber *totalComments) { [self.managedObjectContext performBlock:^{ NSError *error; ReaderPost *aPost = (ReaderPost *)[self.managedObjectContext existingObjectWithID:postObjectID error:&error]; @@ -554,7 +583,7 @@ - (void)syncHierarchicalCommentsForPost:(ReaderPost *)post } dispatch_async(dispatch_get_main_queue(), ^{ - success([comments count], hasMore); + success(hasMore, totalComments); }); }]; }]; @@ -1166,6 +1195,13 @@ - (NSArray *)topLevelCommentsForPage:(NSUInteger)page forPost:(ReaderPost *)post return fetchedObjects; } +- (NSArray *)topLevelComments:(NSUInteger)number forPost:(ReaderPost *)post +{ + NSArray *comments = [self topLevelCommentsForPage:1 forPost:post]; + NSInteger count = MIN(comments.count, number); + return [comments subarrayWithRange:NSMakeRange(0, count)]; +} + - (Comment *)firstCommentForPage:(NSUInteger)page forPost:(ReaderPost *)post { NSArray *comments = [self topLevelCommentsForPage:page forPost:post]; diff --git a/WordPress/Classes/Services/LikeUserHelpers.swift b/WordPress/Classes/Services/LikeUserHelpers.swift index a89d80e975c6..6eef7a098b43 100644 --- a/WordPress/Classes/Services/LikeUserHelpers.swift +++ b/WordPress/Classes/Services/LikeUserHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import CoreData /// Helper class for creating LikeUser objects. /// Used by PostService and CommentService when fetching likes for posts/comments. @@ -19,8 +20,10 @@ import Foundation liker.likedSiteID = remoteUser.likedSiteID?.int64Value ?? 0 liker.likedPostID = remoteUser.likedPostID?.int64Value ?? 0 liker.likedCommentID = remoteUser.likedCommentID?.int64Value ?? 0 - liker.preferredBlog = createPreferredBlogFrom(remotePreferredBlog: remoteUser.preferredBlog, forUser: liker, context: context) liker.dateFetched = Date() + + updatePreferredBlog(for: liker, with: remoteUser, context: context) + return liker } @@ -36,22 +39,23 @@ import Foundation return try? context.fetch(request).first } - private class func createPreferredBlogFrom(remotePreferredBlog: RemoteLikeUserPreferredBlog?, - forUser user: LikeUser, - context: NSManagedObjectContext) -> LikeUserPreferredBlog? { + private class func updatePreferredBlog(for user: LikeUser, with remoteUser: RemoteLikeUser, context: NSManagedObjectContext) { + guard let remotePreferredBlog = remoteUser.preferredBlog else { + if let existingPreferredBlog = user.preferredBlog { + context.deleteObject(existingPreferredBlog) + user.preferredBlog = nil + } - guard let remotePreferredBlog = remotePreferredBlog, - let preferredBlog = user.preferredBlog ?? NSEntityDescription.insertNewObject(forEntityName: "LikeUserPreferredBlog", into: context) as? LikeUserPreferredBlog else { - return nil + return } + let preferredBlog = user.preferredBlog ?? LikeUserPreferredBlog(context: context) + preferredBlog.blogUrl = remotePreferredBlog.blogUrl preferredBlog.blogName = remotePreferredBlog.blogName preferredBlog.iconUrl = remotePreferredBlog.iconUrl preferredBlog.blogID = remotePreferredBlog.blogID?.int64Value ?? 0 preferredBlog.user = user - - return preferredBlog } class func purgeStaleLikes() { diff --git a/WordPress/Classes/Services/MediaService.m b/WordPress/Classes/Services/MediaService.m index c4627b274c96..749c1dbd3f30 100644 --- a/WordPress/Classes/Services/MediaService.m +++ b/WordPress/Classes/Services/MediaService.m @@ -453,6 +453,14 @@ - (void)deleteMedia:(nonnull Media *)media void (^successBlock)(void) = ^() { [self.managedObjectContext performBlock:^{ Media *mediaInContext = (Media *)[self.managedObjectContext existingObjectWithID:mediaObjectID error:nil]; + + if (mediaInContext == nil) { + // Considering the intent of calling this method is to delete the media object, + // when it doesn't exist, we can simply signal success, since the intent is fulfilled. + success(); + return; + } + [self.managedObjectContext deleteObject:mediaInContext]; [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ diff --git a/WordPress/Classes/Services/ReaderTopicService.h b/WordPress/Classes/Services/ReaderTopicService.h index d082d88c1e34..0208529fcad6 100644 --- a/WordPress/Classes/Services/ReaderTopicService.h +++ b/WordPress/Classes/Services/ReaderTopicService.h @@ -113,7 +113,10 @@ extern NSString * const ReaderTopicFreshlyPressedPathCommponent; @param success block called on a successful fetch. @param failure block called if there is any error. `error` can be any underlying network error. */ -- (void)followTagNamed:(NSString *)tagName withSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure; +- (void)followTagNamed:(NSString *)tagName + withSuccess:(void (^)(void))success + failure:(void (^)(NSError *error))failure + source:(NSString *)source; /** Follow the tag with the specified slug diff --git a/WordPress/Classes/Services/ReaderTopicService.m b/WordPress/Classes/Services/ReaderTopicService.m index e5eaba0bdff1..e5fae98498d2 100644 --- a/WordPress/Classes/Services/ReaderTopicService.m +++ b/WordPress/Classes/Services/ReaderTopicService.m @@ -354,14 +354,17 @@ - (void)unfollowTag:(ReaderTagTopic *)topic withSuccess:(void (^)(void))success }]; } -- (void)followTagNamed:(NSString *)topicName withSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure +- (void)followTagNamed:(NSString *)topicName + withSuccess:(void (^)(void))success + failure:(void (^)(NSError *error))failure + source:(NSString *)source { topicName = [[topicName lowercaseString] trim]; ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; [remoteService followTopicNamed:topicName withSuccess:^(NSNumber *topicID) { [self fetchReaderMenuWithSuccess:^{ - NSDictionary *properties = @{@"tag":topicName}; + NSDictionary *properties = @{@"tag":topicName, @"source":source}; [WPAnalytics trackReaderStat:WPAnalyticsStatReaderTagFollowed properties:properties]; [self selectTopicWithID:topicID]; if (success) { diff --git a/WordPress/Classes/System/WindowManager.swift b/WordPress/Classes/System/WindowManager.swift index ba35331b7ece..1ee0ac3f95e2 100644 --- a/WordPress/Classes/System/WindowManager.swift +++ b/WordPress/Classes/System/WindowManager.swift @@ -26,9 +26,9 @@ class WindowManager: NSObject { /// Shows the initial UI for the App to be shown right after launch. This method will present the sign-in flow if the user is not /// authenticated, or the actuall App UI if the user is already authenticated. /// - public func showUI() { + public func showUI(for blog: Blog? = nil) { if AccountHelper.isLoggedIn { - showAppUI() + showAppUI(for: blog) } else { showSignInUI() } @@ -45,20 +45,25 @@ class WindowManager: NSObject { showSignInUI() } - func dismissFullscreenSignIn(completion: Completion? = nil) { + func dismissFullscreenSignIn(blogToShow: Blog? = nil, completion: Completion? = nil) { guard isShowingFullscreenSignIn == true && AccountHelper.isLoggedIn == true else { return } - showAppUI(completion: completion) + showAppUI(for: blogToShow, completion: completion) } /// Shows the UI for authenticated users. /// - func showAppUI(completion: Completion? = nil) { + func showAppUI(for blog: Blog? = nil, completion: Completion? = nil) { isShowingFullscreenSignIn = false - show(WPTabBarController.sharedInstance(), completion: completion) + + guard let blog = blog else { + return + } + + WPTabBarController.sharedInstance()?.showBlogDetails(for: blog) } /// Shows the initial UI for unauthenticated users. diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 4ca17a3eba17..6a75e79de2c2 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -254,6 +254,17 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { return true } + // Note that this method only appears to be called for iPhone devices, not iPad. + // This allows individual view controllers to cancel rotation if they need to. + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + if let vc = window?.topmostPresentedViewController, + vc is OrientationLimited { + return vc.supportedInterfaceOrientations + } + + return application.supportedInterfaceOrientations(for: window) + } + // MARK: - Setup func runStartupSequence(with launchOptions: [UIApplication.LaunchOptionsKey: Any] = [:]) { diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index cb93edbe4192..f4bd60710fb5 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -210,6 +210,56 @@ import Foundation case domainsRegistrationFormSubmitted case domainsPurchaseWebviewViewed + // My Site: No sites view displayed + case mySiteNoSitesViewDisplayed + case mySiteNoSitesViewActionTapped + case mySiteNoSitesViewHidden + + // Site Switcher + case mySiteSiteSwitcherTapped + case siteSwitcherDisplayed + case siteSwitcherDismissed + case siteSwitcherToggleEditTapped + case siteSwitcherAddSiteTapped + case siteSwitcherSearchPerformed + case siteSwitcherToggleBlogVisible + + // Post List + case postListShareAction + case postListSetAsPostsPageAction + case postListSetHomePageAction + + // Reader: Filter Sheet + case readerFilterSheetDisplayed + case readerFilterSheetDismissed + case readerFilterSheetItemSelected + case readerFilterSheetCleared + + // Reader: Manage + case readerManageViewDisplayed + case readerManageViewDismissed + + // App Settings + case settingsDidChange + + // Account Close + case accountCloseTapped + case accountCloseCompleted + + // App Settings + case appSettingsClearMediaCacheTapped + case appSettingsClearSpotlightIndexTapped + case appSettingsClearSiriSuggestionsTapped + case appSettingsOpenDeviceSettingsTapped + + // Privacy Settings + case privacySettingsOpened + case privacySettingsReportCrashesToggled + + // Login: Epilogue + case loginEpilogueChooseSiteTapped + case loginEpilogueCreateNewSiteTapped + /// A String that represents the event var value: String { switch self { @@ -574,7 +624,83 @@ import Foundation return "domains_registration_form_submitted" case .domainsPurchaseWebviewViewed: return "domains_purchase_webview_viewed" - } + + // My Site No Sites View + case .mySiteNoSitesViewDisplayed: + return "my_site_no_sites_view_displayed" + case .mySiteNoSitesViewActionTapped: + return "my_site_no_sites_view_action_tapped" + case .mySiteNoSitesViewHidden: + return "my_site_no_sites_view_hidden" + + // Site Switcher + case .mySiteSiteSwitcherTapped: + return "my_site_site_switcher_tapped" + case .siteSwitcherDisplayed: + return "site_switcher_displayed" + case .siteSwitcherDismissed: + return "site_switcher_dismissed" + case .siteSwitcherToggleEditTapped: + return "site_switcher_toggle_edit_tapped" + case .siteSwitcherAddSiteTapped: + return "site_switcher_add_site_tapped" + case .siteSwitcherSearchPerformed: + return "site_switcher_search_performed" + case .siteSwitcherToggleBlogVisible: + return "site_switcher_toggle_blog_visible" + case .postListShareAction: + return "post_list_button_pressed" + case .postListSetAsPostsPageAction: + return "post_list_button_pressed" + case .postListSetHomePageAction: + return "post_list_button_pressed" + + // Reader: Filter Sheet + case .readerFilterSheetDisplayed: + return "reader_filter_sheet_displayed" + case .readerFilterSheetDismissed: + return "reader_filter_sheet_dismissed" + case .readerFilterSheetItemSelected: + return "reader_filter_sheet_item_selected" + case .readerFilterSheetCleared: + return "reader_filter_sheet_cleared" + + // Reader: Manage View + case .readerManageViewDisplayed: + return "reader_manage_view_displayed" + case .readerManageViewDismissed: + return "reader_manage_view_dismissed" + + // App Settings + case .settingsDidChange: + return "settings_did_change" + case .appSettingsClearMediaCacheTapped: + return "app_settings_clear_media_cache_tapped" + case .appSettingsClearSpotlightIndexTapped: + return "app_settings_clear_spotlight_index_tapped" + case .appSettingsClearSiriSuggestionsTapped: + return "app_settings_clear_siri_suggestions_tapped" + case .appSettingsOpenDeviceSettingsTapped: + return "app_settings_open_device_settings_tapped" + + // Privacy Settings + case .privacySettingsOpened: + return "privacy_settings_opened" + case .privacySettingsReportCrashesToggled: + return "privacy_settings_report_crashes_toggled" + + // Account Close + case .accountCloseTapped: + return "account_close_tapped" + case .accountCloseCompleted: + return "account_close_completed" + + // Login: Epilogue + case .loginEpilogueChooseSiteTapped: + return "login_epilogue_choose_site_tapped" + case .loginEpilogueCreateNewSiteTapped: + return "login_epilogue_create_new_site_tapped" + } // END OF SWITCH } /** @@ -592,6 +718,12 @@ import Foundation return ["via": "tenor"] case .editorAddedPhotoViaTenor: return ["via": "tenor"] + case .postListShareAction: + return ["button": "share"] + case .postListSetAsPostsPageAction: + return ["button": "set_posts_page"] + case .postListSetHomePageAction: + return ["button": "set_homepage"] default: return nil } @@ -735,4 +867,18 @@ extension WPAnalytics { } } + @objc static func trackSettingsChange(_ page: String, fieldName: String) { + Self.trackSettingsChange(page, fieldName: fieldName, value: nil) + } + + @objc static func trackSettingsChange(_ page: String, fieldName: String, value: Any?) { + var properties: [AnyHashable: Any] = ["page": page, "field_name": fieldName] + + if let value = value { + let additionalProperties: [AnyHashable: Any] = ["value": value] + properties.merge(additionalProperties) { (_, new) in new } + } + + WPAnalytics.track(.settingsDidChange, properties: properties) + } } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerAutomatticTracks.m b/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerAutomatticTracks.m index 3511ee45d122..a7d3e2b24b1b 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerAutomatticTracks.m +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerAutomatticTracks.m @@ -1748,6 +1748,12 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatStatsItemTappedInsightsAddStat: eventName = @"stats_add_insight_item_tapped"; break; + case WPAnalyticsStatStatsItemTappedPostStatsMonthsYears: + eventName = @"stats_posts_and_pages_months_years_item_tapped"; + break; + case WPAnalyticsStatStatsItemTappedPostStatsRecentWeeks: + eventName = @"stats_posts_and_pages_recent_weeks_item_tapped"; + break; case WPAnalyticsStatStatsItemTappedInsightsCustomizeDismiss: eventName = @"stats_customize_insights_dismiss_item_tapped"; break; diff --git a/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift b/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift index 17d7eb61449d..f679b5238d2c 100644 --- a/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift +++ b/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift @@ -163,7 +163,7 @@ class WeeklyRoundupDataProvider { /// Filters the sites that have the Weekly Roundup notification setting enabled. /// private func filterWeeklyRoundupEnabledSites(_ sites: [Blog], result: @escaping (Result<[Blog], Error>) -> Void) { - let noteService = NotificationSettingsService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let noteService = NotificationSettingsService(managedObjectContext: context) noteService.getAllSettings { settings in let weeklyRoundupEnabledSites = sites.filter { site in @@ -377,7 +377,7 @@ class WeeklyRoundupBackgroundTask: BackgroundTask { } } - let dataProvider = WeeklyRoundupDataProvider(context: ContextManager.shared.mainContext, onError: onError) + let dataProvider = WeeklyRoundupDataProvider(context: ContextManager.shared.newDerivedContext(), onError: onError) var siteStats: [Blog: StatsSummaryData]? = nil let requestData = BlockOperation { diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 37517e64b86c..9dae6f0fc427 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -19,6 +19,8 @@ enum FeatureFlag: Int, CaseIterable, OverrideableFlag { case domains case followConversationViaNotifications case aboutScreen + case newCommentThread + case postDetailsComments /// Returns a boolean indicating if the feature is enabled var enabled: Bool { @@ -62,7 +64,11 @@ enum FeatureFlag: Int, CaseIterable, OverrideableFlag { case .followConversationViaNotifications: return true case .aboutScreen: - return BuildConfiguration.current == .localDeveloper + return true + case .newCommentThread: + return false + case .postDetailsComments: + return false } } @@ -123,6 +129,10 @@ extension FeatureFlag { return "Follow Conversation via Notifications" case .aboutScreen: return "New Unified About Screen" + case .newCommentThread: + return "New Comment Thread" + case .postDetailsComments: + return "Post Details Comments" } } diff --git a/WordPress/Classes/Utility/WPImmuTableRows.swift b/WordPress/Classes/Utility/WPImmuTableRows.swift index d8860d8b9825..31f2fc4678a0 100644 --- a/WordPress/Classes/Utility/WPImmuTableRows.swift +++ b/WordPress/Classes/Utility/WPImmuTableRows.swift @@ -80,12 +80,14 @@ struct EditableTextRow: ImmuTableRow { let value: String let accessoryImage: UIImage? let action: ImmuTableAction? + let fieldName: String? - init(title: String, value: String, accessoryImage: UIImage? = nil, action: ImmuTableAction?) { + init(title: String, value: String, accessoryImage: UIImage? = nil, action: ImmuTableAction?, fieldName: String? = nil) { self.title = title self.value = value self.accessoryImage = accessoryImage self.action = action + self.fieldName = fieldName } func configureCell(_ cell: UITableViewCell) { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift index 0800a60f499c..e4fa95bee0fc 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift @@ -56,7 +56,7 @@ extension BlogDetailsViewController { return } let newWorkItem = DispatchWorkItem { [weak self] in - self?.showNoticeOrAlertAsNeeded() + self?.showNoticeAsNeeded() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: newWorkItem) alertWorkItem = newWorkItem @@ -80,13 +80,8 @@ extension BlogDetailsViewController { return false } - private func showNoticeOrAlertAsNeeded() { - - if QuickStartTourGuide.shared.shouldShowUpgradeToV2Notice(for: blog) { - showUpgradeToV2Alert(for: blog) - - QuickStartTourGuide.shared.didShowUpgradeToV2Notice(for: blog) - } else if let tourToSuggest = QuickStartTourGuide.shared.tourToSuggest(for: blog) { + private func showNoticeAsNeeded() { + if let tourToSuggest = QuickStartTourGuide.shared.tourToSuggest(for: blog) { QuickStartTourGuide.shared.suggest(tourToSuggest, for: blog) } } @@ -165,17 +160,4 @@ extension BlogDetailsViewController { section.showQuickStartMenu = true return section } - - private func showUpgradeToV2Alert(for blog: Blog) { - guard noPresentedViewControllers else { - return - } - - let alert = FancyAlertViewController.makeQuickStartUpgradeToV2AlertController(blog: blog) - alert.modalPresentationStyle = .custom - alert.transitioningDelegate = self - tabBarController?.present(alert, animated: true) - - WPAnalytics.track(.quickStartMigrationDialogViewed) - } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index b308af4a11d5..8a71596d6835 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -1155,6 +1155,8 @@ - (void)siteSwitcherTapped UINavigationController* navigationController = [[UINavigationController alloc] initWithRootViewController:blogListViewController]; navigationController.modalPresentationStyle = UIModalPresentationFormSheet; [self presentViewController:navigationController animated:true completion:nil]; + + [WPAnalytics trackEvent:WPAnalyticsEventMySiteSiteSwitcherTapped]; } - (void)visitSiteTapped @@ -1715,7 +1717,6 @@ - (void)showMediaLibraryFromSource:(BlogDetailsNavigationSource)source - (void)showPeople { - [WPAppAnalytics track:WPAnalyticsStatOpenedPeople withBlog:self.blog]; PeopleViewController *controller = [PeopleViewController controllerWithBlog:self.blog]; controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; [self showDetailViewController:controller sender:self]; diff --git a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m index b4d07681d8a6..7ed90e295545 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m @@ -154,6 +154,8 @@ - (void)viewDidLoad [self registerForAccountChangeNotification]; [self registerForPostSignUpNotifications]; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherDisplayed]; } - (void)viewWillAppear:(BOOL)animated @@ -186,6 +188,8 @@ - (void)viewWillDisappear:(BOOL)animated [self.searchBar resignFirstResponder]; } self.visible = NO; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherDismissed]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator @@ -796,6 +800,8 @@ - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:( - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { self.dataSource.searchQuery = searchText; + + [self debounce:@selector(trackSearchPerformed) afterDelay:0.5f]; } - (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar @@ -804,6 +810,11 @@ - (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar [searchBar setShowsCancelButton:YES animated:YES]; } +- (void)trackSearchPerformed +{ + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherSearchPerformed]; +} + - (void)searchBarTextDidEndEditing:(UISearchBar *)searchBar { self.dataSource.searching = NO; @@ -852,6 +863,8 @@ - (void)setEditing:(BOOL)editing animated:(BOOL)animated [self updateViewsForCurrentSiteCount]; [self updateSearchVisibility]; } + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherToggleEditTapped properties: @{ @"state": editing ? @"edit" : @"done"}]; + } - (BOOL)shouldShowAddSiteButton @@ -950,6 +963,9 @@ - (void)setVisible:(BOOL)visible forBlog:(Blog *)blog } AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] mainContext]]; [accountService setVisibility:visible forBlogs:@[blog]]; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherToggleBlogVisible properties:@{ @"visible": @(visible)} blog:blog]; + } #pragma mark - Data Listener @@ -997,6 +1013,9 @@ - (void)showAddSiteAlertFrom:(id)source [self presentViewController:alertController animated:YES completion:nil]; self.addSiteAlertController = alertController; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherAddSiteTapped]; + } } diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift index e9cc73cfa97b..f4c792f2aef3 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift @@ -73,6 +73,8 @@ class MySiteViewController: UIViewController, NoResultsViewHost { } FancyAlertViewController.presentCustomAppIconUpgradeAlertIfNecessary(from: self) + + trackNoSitesVisibleIfNeeded() } private func subscribeToPostSignupNotifications() { @@ -173,6 +175,11 @@ class MySiteViewController: UIViewController, NoResultsViewHost { // MARK: - No Sites UI logic private func hideNoSites() { + // Only track if the no sites view is currently visible + if noResultsViewController.view.superview != nil { + WPAnalytics.track(.mySiteNoSitesViewHidden) + } + hideNoResults() cleanupNoResultsView() @@ -197,6 +204,14 @@ class MySiteViewController: UIViewController, NoResultsViewHost { addNoResultsViewAndConfigureConstraints() } + private func trackNoSitesVisibleIfNeeded() { + guard noResultsViewController.view.superview != nil else { + return + } + + WPAnalytics.track(.mySiteNoSitesViewDisplayed) + } + private func makeNoResultsScrollView() { let scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -223,6 +238,7 @@ class MySiteViewController: UIViewController, NoResultsViewHost { image: "mysites-nosites") noResultsViewController.actionButtonHandler = { [weak self] in self?.presentInterfaceForAddingNewSite() + WPAnalytics.track(.mySiteNoSitesViewActionTapped) } } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.swift new file mode 100644 index 000000000000..0686073924de --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.swift @@ -0,0 +1,146 @@ +import UIKit + +final class QuickStartPromptViewController: UIViewController { + + // MARK: - IBOutlets + + /// Site info + @IBOutlet private weak var siteIconView: UIImageView! + @IBOutlet private weak var siteTitleLabel: UILabel! + @IBOutlet private weak var siteDescriptionLabel: UILabel! + + /// Prompt info + @IBOutlet private weak var promptTitleLabel: UILabel! + @IBOutlet private weak var promptDescriptionLabel: UILabel! + + /// Buttons + @IBOutlet private weak var showMeAroundButton: FancyButton! + @IBOutlet private weak var noThanksButton: FancyButton! + + // MARK: - Properties + + private let blog: Blog + private let quickStartSettings: QuickStartSettings + + /// Closure to be executed upon dismissal. + /// + /// - Parameters: + /// - Blog: the blog for which the prompt was dismissed + /// - Bool: `true` if Quick Start should start, otherwise `false` + var onDismiss: ((Blog, Bool) -> Void)? + + // MARK: - Init + + init(blog: Blog, quickStartSettings: QuickStartSettings = QuickStartSettings()) { + self.blog = blog + self.quickStartSettings = quickStartSettings + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + applyStyles() + setup() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIAccessibility.post(notification: .layoutChanged, argument: promptTitleLabel) + } + + // MARK: - Styling + + private func applyStyles() { + siteTitleLabel.numberOfLines = 0 + siteTitleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + siteTitleLabel.adjustsFontForContentSizeCategory = true + siteTitleLabel.adjustsFontSizeToFitWidth = true + siteTitleLabel.textColor = .text + + siteDescriptionLabel.numberOfLines = 0 + siteDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + siteDescriptionLabel.adjustsFontForContentSizeCategory = true + siteDescriptionLabel.adjustsFontSizeToFitWidth = true + siteDescriptionLabel.textColor = .textSubtle + + promptTitleLabel.numberOfLines = 0 + promptTitleLabel.font = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .medium) + promptTitleLabel.adjustsFontForContentSizeCategory = true + promptTitleLabel.adjustsFontSizeToFitWidth = true + promptTitleLabel.textColor = .text + + promptDescriptionLabel.numberOfLines = 0 + promptDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + promptDescriptionLabel.adjustsFontForContentSizeCategory = true + promptDescriptionLabel.adjustsFontSizeToFitWidth = true + promptDescriptionLabel.textColor = .textSubtle + + showMeAroundButton.isPrimary = true + noThanksButton.isPrimary = false + } + + // MARK: - Setup + + private func setup() { + setupSiteInfoViews() + setupPromptInfoViews() + setupButtons() + } + + private func setupSiteInfoViews() { + siteIconView.downloadSiteIcon(for: blog) + + let displayURL = blog.displayURL as String? ?? "" + if let name = blog.settings?.name?.nonEmptyString() { + siteTitleLabel.text = name + siteDescriptionLabel.text = displayURL + } else { + siteTitleLabel.text = displayURL + siteDescriptionLabel.text = nil + } + } + + private func setupPromptInfoViews() { + promptTitleLabel.text = Strings.promptTitle + promptDescriptionLabel.text = Strings.promptDescription + } + + private func setupButtons() { + showMeAroundButton.setTitle(Strings.showMeAroundButtonTitle, for: .normal) + noThanksButton.setTitle(Strings.noThanksButtonTitle, for: .normal) + } + + // MARK: - IBAction + + @IBAction private func showMeAroundButtonTapped(_ sender: Any) { + onDismiss?(blog, true) + dismiss(animated: true) + + WPAnalytics.track(.quickStartRequestAlertButtonTapped, withProperties: ["type": "positive"]) + } + + @IBAction private func noThanksButtonTapped(_ sender: Any) { + quickStartSettings.setPromptWasDismissed(true, for: blog) + onDismiss?(blog, false) + dismiss(animated: true) + + WPAnalytics.track(.quickStartRequestAlertButtonTapped, withProperties: ["type": "neutral"]) + } +} + +extension QuickStartPromptViewController { + + private enum Strings { + static let promptTitle = NSLocalizedString("Want a little help managing this site with the app?", comment: "Title for a prompt asking if users want to try out the quick start checklist.") + static let promptDescription = NSLocalizedString("Learn the basics with a quick walk through.", comment: "Description for a prompt asking if users want to try out the quick start checklist.") + static let showMeAroundButtonTitle = NSLocalizedString("Show me around", comment: "Button title. When tapped, the quick start checklist will be shown.") + static let noThanksButtonTitle = NSLocalizedString("No thanks", comment: "Button title. When tapped, the quick start checklist will not be shown, and the prompt will be dismissed.") + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.xib new file mode 100644 index 000000000000..ce4fb7a7bfb1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.xib @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartSettings.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartSettings.swift new file mode 100644 index 000000000000..4e73302acf49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartSettings.swift @@ -0,0 +1,44 @@ +import Foundation + +final class QuickStartSettings { + + private let userDefaults: UserDefaults + + // MARK: - Init + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + // MARK: - Quick Start availability + + func isQuickStartAvailable(for blog: Blog) -> Bool { + return blog.isUserCapableOf(.ManageOptions) && + blog.isUserCapableOf(.EditThemeOptions) && + !blog.isWPForTeams() + } + + // MARK: - User Defaults Storage + + func promptWasDismissed(for blog: Blog) -> Bool { + guard let key = promptWasDismissedKey(for: blog) else { + return false + } + return userDefaults.bool(forKey: key) + } + + func setPromptWasDismissed(_ value: Bool, for blog: Blog) { + guard let key = promptWasDismissedKey(for: blog) else { + return + } + userDefaults.set(value, forKey: key) + } + + private func promptWasDismissedKey(for blog: Blog) -> String? { + guard let siteID = blog.dotComID?.intValue else { + return nil + } + return "QuickStartPromptWasDismissed-\(siteID)" + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift index 72e867d36138..e1a6210b544e 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift @@ -17,8 +17,6 @@ open class QuickStartTourGuide: NSObject { private override init() {} func setup(for blog: Blog, withCompletedSteps steps: [QuickStartTour] = []) { - didShowUpgradeToV2Notice(for: blog) - let createTour = QuickStartCreateTour() completed(tour: createTour, for: blog) @@ -28,6 +26,12 @@ open class QuickStartTourGuide: NSObject { } } + func setupWithDelay(for blog: Blog, withCompletedSteps steps: [QuickStartTour] = []) { + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.quickStartDelay) { + self.setup(for: blog, withCompletedSteps: steps) + } + } + @objc func remove(from blog: Blog) { blog.removeAllTours() } @@ -38,21 +42,6 @@ open class QuickStartTourGuide: NSObject { return checklistCompletedCount > 0 } - func shouldShowUpgradeToV2Notice(for blog: Blog) -> Bool { - guard isQuickStartEnabled(for: blog), - !allOriginalToursCompleted(for: blog) else { - return false - } - - let completedIDs = blog.completedQuickStartTours?.map { $0.tourID } ?? [] - return !completedIDs.contains(QuickStartUpgradeToV2Tour().key) - } - - func didShowUpgradeToV2Notice(for blog: Blog) { - let v2tour = QuickStartUpgradeToV2Tour() - blog.completeTour(v2tour.key) - } - /// Provides a tour to suggest to the user /// /// - Parameter blog: The Blog for which to suggest a tour. @@ -433,6 +422,7 @@ private extension QuickStartTourGuide { private struct Constants { static let maxSkippedTours = 3 static let suggestionTimeout = 10.0 + static let quickStartDelay: DispatchTimeInterval = .milliseconds(500) } } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift index 75bc738377d1..d84cb89bd499 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift @@ -69,23 +69,6 @@ struct QuickStartCreateTour: QuickStartTour { let accessibilityHintText = NSLocalizedString("Guides you through the process of creating your site.", comment: "This value is used to set the accessibility hint text for creating the user's site.") } -/// This is used to track when users from v1 are shown the v2 upgrade notice -/// This should also be created when a site is setup for v2 -struct QuickStartUpgradeToV2Tour: QuickStartTour { - let key = "quick-start-upgrade-to-v2" - let analyticsKey = "upgrade_to_v2" - let title = "" - let titleMarkedCompleted = "" - let description = "" - let icon = UIImage.gridicon(.plus) - let suggestionNoText = Strings.notNow - let suggestionYesText = Strings.yesShowMe - - let waypoints: [QuickStartTour.WayPoint] = [] - - let accessibilityHintText = "" // not applicable for this tour type -} - struct QuickStartViewTour: QuickStartTour { let key = "quick-start-view-tour" let analyticsKey = "view_site" @@ -127,8 +110,8 @@ struct QuickStartThemeTour: QuickStartTour { struct QuickStartShareTour: QuickStartTour { let key = "quick-start-share-tour" let analyticsKey = "share_site" - let title = NSLocalizedString("Enable post sharing", comment: "Title of a Quick Start Tour") - let titleMarkedCompleted = NSLocalizedString("Completed: Enable post sharing", comment: "The Quick Start Tour title after the user finished the step.") + let title = NSLocalizedString("Social sharing", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Social sharing", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Automatically share new posts to your social media accounts.", comment: "Description of a Quick Start Tour") let icon = UIImage.gridicon(.share) let suggestionNoText = Strings.notNow @@ -154,7 +137,7 @@ struct QuickStartPublishTour: QuickStartTour { let analyticsKey = "publish_post" let title = NSLocalizedString("Publish a post", comment: "Title of a Quick Start Tour") let titleMarkedCompleted = NSLocalizedString("Completed: Publish a post", comment: "The Quick Start Tour title after the user finished the step.") - let description = NSLocalizedString("It's time! Draft and publish your very first post.", comment: "Description of a Quick Start Tour") + let description = NSLocalizedString("Draft and publish a post.", comment: "Description of a Quick Start Tour") let icon = UIImage.gridicon(.create) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe @@ -204,8 +187,8 @@ struct QuickStartFollowTour: QuickStartTour { struct QuickStartSiteTitleTour: QuickStartTour { let key = "quick-start-site-title-tour" let analyticsKey = "site_title" - let title = NSLocalizedString("Set your site title", comment: "Title of a Quick Start Tour") - let titleMarkedCompleted = NSLocalizedString("Completed: Set your site title", comment: "The Quick Start Tour title after the user finished the step.") + let title = NSLocalizedString("Check your site title", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Check your site title", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Give your site a name that reflects its personality and topic. First impressions count!", comment: "Description of a Quick Start Tour") let icon = UIImage.gridicon(.pencil) @@ -225,9 +208,9 @@ struct QuickStartSiteTitleTour: QuickStartTour { struct QuickStartSiteIconTour: QuickStartTour { let key = "quick-start-site-icon-tour" let analyticsKey = "site_icon" - let title = NSLocalizedString("Upload a site icon", comment: "Title of a Quick Start Tour") - let titleMarkedCompleted = NSLocalizedString("Completed: Upload a site icon", comment: "The Quick Start Tour title after the user finished the step.") - let description = NSLocalizedString("Your visitors will see your icon in their browser. Add a custom icon for a polished, pro look.", comment: "Description of a Quick Start Tour") + let title = NSLocalizedString("Choose a unique site icon", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Choose a unique site icon", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Shown in your visitor's browser tab and other places online.", comment: "Description of a Quick Start Tour") let icon = UIImage.gridicon(.globe) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift index 82591c7be3c9..7f226c051459 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift @@ -1,5 +1,6 @@ import UIKit import Gridicons +import WordPressShared final class SiteTagsViewController: UITableViewController { private struct TableConstants { @@ -309,6 +310,7 @@ extension SiteTagsViewController { newTag.tagDescription = data.subtitle save(newTag) + WPAnalytics.trackSettingsChange("site_tags", fieldName: "add_tag") } private func updateTag(_ tag: PostTag, updatedData: SettingsTitleSubtitleController.Content) { @@ -323,6 +325,7 @@ extension SiteTagsViewController { tag.tagDescription = updatedData.subtitle save(tag) + WPAnalytics.trackSettingsChange("site_tags", fieldName: "edit_tag") } private func existingTagForData(_ data: SettingsTitleSubtitleController.Content) -> PostTag? { diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift index ea8d2e6a92b2..3e94841d3586 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift @@ -135,6 +135,7 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { if let newDateFormat = selected as? String { self?.settings.dateFormat = newDateFormat self?.saveSettings() + WPAnalytics.trackSettingsChange("date_format", fieldName: "date_format") } } @@ -168,6 +169,8 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { if let newTimeFormat = selected as? String { self?.settings.timeFormat = newTimeFormat self?.saveSettings() + WPAnalytics.trackSettingsChange("date_format", fieldName: "time_format") + } } @@ -187,6 +190,9 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { if let newStartOfWeek = selected as? String { self?.settings.startOfWeek = newStartOfWeek self?.saveSettings() + WPAnalytics.trackSettingsChange("date_format", + fieldName: "start_of_week", + value: newStartOfWeek as Any) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift index 9b6e86d5dabe..5ccfebae13fa 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift @@ -7,6 +7,8 @@ import WordPressShared /// allow the user to tune those settings, as required. /// open class DiscussionSettingsViewController: UITableViewController { + private let tracksDiscussionSettingsKey = "site_settings_discussion" + // MARK: - Initializers / Deinitializers @objc public convenience init(blog: Blog) { self.init(style: .grouped) @@ -183,6 +185,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + trackSettingsChange(fieldName: "allow_comments", value: enabled as Any) settings.commentsAllowed = enabled } @@ -191,6 +194,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + trackSettingsChange(fieldName: "receive_pingbacks", value: enabled as Any) settings.pingbackInboundEnabled = enabled } @@ -199,6 +203,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + trackSettingsChange(fieldName: "send_pingbacks", value: enabled as Any) settings.pingbackOutboundEnabled = enabled } @@ -207,6 +212,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + trackSettingsChange(fieldName: "require_name_and_email", value: enabled as Any) settings.commentsRequireNameAndEmail = enabled } @@ -215,6 +221,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + trackSettingsChange(fieldName: "require_registration", value: enabled as Any) settings.commentsRequireRegistration = enabled } @@ -234,6 +241,9 @@ open class DiscussionSettingsViewController: UITableViewController { pickerViewController.onChange = { [weak self] (enabled: Bool, newValue: Int) in self?.settings.commentsCloseAutomatically = enabled self?.settings.commentsCloseAutomaticallyAfterDays = newValue as NSNumber + + let value: Any = enabled ? newValue : "disabled" + self?.trackSettingsChange(fieldName: "close_commenting", value: value) } navigationController?.pushViewController(pickerViewController, animated: true) @@ -250,6 +260,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + self?.trackSettingsChange(fieldName: "comments_sort_by", value: selected as Any) self?.settings.commentsSorting = newSortOrder } @@ -268,6 +279,7 @@ open class DiscussionSettingsViewController: UITableViewController { } self?.settings.commentsThreading = newThreadingDepth + self?.trackSettingsChange(fieldName: "comments_threading", value: selected as Any) } navigationController?.pushViewController(settingsViewController, animated: true) @@ -287,6 +299,9 @@ open class DiscussionSettingsViewController: UITableViewController { pickerViewController.onChange = { [weak self] (enabled: Bool, newValue: Int) in self?.settings.commentsPagingEnabled = enabled self?.settings.commentsPageSize = newValue as NSNumber + + let value: Any = enabled ? newValue : "disabled" + self?.trackSettingsChange(fieldName: "comments_paging", value: value) } navigationController?.pushViewController(pickerViewController, animated: true) @@ -305,6 +320,7 @@ open class DiscussionSettingsViewController: UITableViewController { } self?.settings.commentsAutoapproval = newApprovalStatus + self?.trackSettingsChange(fieldName: "comments_automatically_approve", value: selected as Any) } navigationController?.pushViewController(settingsViewController, animated: true) @@ -321,6 +337,7 @@ open class DiscussionSettingsViewController: UITableViewController { pickerViewController.pickerSelectedValue = settings.commentsMaximumLinks as? Int pickerViewController.onChange = { [weak self] (enabled: Bool, newValue: Int) in self?.settings.commentsMaximumLinks = newValue as NSNumber + self?.trackSettingsChange(fieldName: "comments_links", value: newValue as Any) } navigationController?.pushViewController(pickerViewController, animated: true) @@ -336,6 +353,7 @@ open class DiscussionSettingsViewController: UITableViewController { comment: "Text rendered at the bottom of the Discussion Moderation Keys editor") settingsViewController.onChange = { [weak self] (updated: Set) in self?.settings.commentsModerationKeys = updated + self?.trackSettingsChange(fieldName: "comments_hold_for_moderation", value: updated.count as Any) } navigationController?.pushViewController(settingsViewController, animated: true) @@ -351,12 +369,18 @@ open class DiscussionSettingsViewController: UITableViewController { comment: "Text rendered at the bottom of the Discussion Blocklist Keys editor") settingsViewController.onChange = { [weak self] (updated: Set) in self?.settings.commentsBlocklistKeys = updated + self?.trackSettingsChange(fieldName: "comments_block_list", value: updated.count as Any) } navigationController?.pushViewController(settingsViewController, animated: true) } + private func trackSettingsChange(fieldName: String, value: Any?) { + WPAnalytics.trackSettingsChange(tracksDiscussionSettingsKey, + fieldName: fieldName, + value: value) + } // MARK: - Computed Properties fileprivate var sections: [Section] { diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift index 785090da84e9..7f18cc14abda 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift @@ -276,6 +276,10 @@ import WordPressShared /// If there is already an in progress change (i.e. bad network), don't push the view controller and deselect the selection immediately. tableView.allowsSelection = false + WPAnalytics.trackSettingsChange("homepage_settings", + fieldName: "homepage_type", + value: (homepageType == .page) ? "page" : "posts") + /// Send the remove service call let service = HomepageSettingsService(blog: blog, context: blog.managedObjectContext!) service?.setHomepageType(homepageType, diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/Related Posts/RelatedPostsSettingsViewController.m b/WordPress/Classes/ViewRelated/Blog/Site Settings/Related Posts/RelatedPostsSettingsViewController.m index 10d56dd8a927..cc4d560fb850 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/Related Posts/RelatedPostsSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/Related Posts/RelatedPostsSettingsViewController.m @@ -190,6 +190,8 @@ - (SwitchTableViewCell *)relatedPostsEnabledCell _relatedPostsEnabledCell.name = NSLocalizedString(@"Show Related Posts", @"Label for configuration switch to enable/disable related posts"); __weak RelatedPostsSettingsViewController *weakSelf = self; _relatedPostsEnabledCell.onChange = ^(BOOL value){ + [WPAnalytics trackSettingsChange:@"related_posts" fieldName:@"show_related_posts" value:@(value)]; + [weakSelf updateRelatedPostsSettings:nil]; }; } @@ -203,6 +205,7 @@ - (SwitchTableViewCell *)relatedPostsShowHeaderCell _relatedPostsShowHeaderCell.name = NSLocalizedString(@"Show Header", @"Label for configuration switch to show/hide the header for the related posts section"); __weak RelatedPostsSettingsViewController *weakSelf = self; _relatedPostsShowHeaderCell.onChange = ^(BOOL value){ + [WPAnalytics trackSettingsChange:@"related_posts" fieldName:@"show_related_posts_header" value:@(value)]; [weakSelf updateRelatedPostsSettings:nil]; }; } @@ -217,6 +220,8 @@ - (SwitchTableViewCell *)relatedPostsShowThumbnailsCell _relatedPostsShowThumbnailsCell.name = NSLocalizedString(@"Show Images", @"Label for configuration switch to show/hide images thumbnail for the related posts"); __weak RelatedPostsSettingsViewController *weakSelf = self; _relatedPostsShowThumbnailsCell.onChange = ^(BOOL value){ + [WPAnalytics trackSettingsChange:@"related_posts" fieldName:@"show_related_posts_thumbnail" value:@(value)]; + [weakSelf updateRelatedPostsSettings:nil]; }; } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index 9f36f04cf119..665e69a4f48d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -79,6 +79,8 @@ extension SiteSettingsViewController { self?.blog.settings?.gmtOffset = newValue.gmtOffset as NSNumber? self?.blog.settings?.timezoneString = newValue.timezoneString self?.saveSettings() + self?.trackSettingsChange(fieldName: "timezone", + value: newValue.value as Any) } navigationController?.pushViewController(controller, animated: true) } @@ -105,6 +107,7 @@ extension SiteSettingsViewController { pickerViewController.onChange = { [weak self] (enabled: Bool, newValue: Int) in self?.blog.settings?.postsPerPage = newValue as NSNumber? self?.saveSettings() + self?.trackSettingsChange(fieldName: "posts_per_page", value: newValue as Any) } navigationController?.pushViewController(pickerViewController, animated: true) @@ -355,6 +358,8 @@ extension SiteSettingsViewController { if value != self.blog.settings?.name { self.blog.settings?.name = value self.saveSettings() + + self.trackSettingsChange(fieldName: "site_title") } } @@ -385,6 +390,8 @@ extension SiteSettingsViewController { if normalizedTagline != self.blog.settings?.tagline { self.blog.settings?.tagline = normalizedTagline self.saveSettings() + + self.trackSettingsChange(fieldName: "tagline") } } @@ -403,4 +410,10 @@ extension SiteSettingsViewController { tableView.deselectRow(at: indexPath, animated: true) } + + func trackSettingsChange(fieldName: String, value: Any? = nil) { + WPAnalytics.trackSettingsChange("site_settings", + fieldName: fieldName, + value: value) + } } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m index d2e3f3738c95..89186be40a23 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m @@ -438,6 +438,7 @@ - (SwitchTableViewCell *)ampSettingCell _ampSettingCell.onChange = ^(BOOL value){ weakSelf.blog.settings.ampEnabled = value; [weakSelf saveSettings]; + [WPAnalytics trackSettingsChange:@"site_settings" fieldName:@"amp_enabled" value:@(value)]; }; return _ampSettingCell; @@ -751,6 +752,7 @@ - (void)showPrivacySelector if (weakSelf.blog.siteVisibility != newSiteVisibility) { weakSelf.blog.siteVisibility = newSiteVisibility; [weakSelf saveSettings]; + [WPAnalytics trackSettingsChange:@"site_settings" fieldName:@"privacy" value:status]; } } }; @@ -768,6 +770,7 @@ - (void)showLanguageSelectorForBlog:(Blog *)blog languageViewController.onChange = ^(NSNumber *newLanguageID){ weakSelf.blog.settings.languageID = newLanguageID; [weakSelf saveSettings]; + [WPAnalytics trackSettingsChange:@"site_settings" fieldName:@"language" value:newLanguageID]; }; [self.navigationController pushViewController:languageViewController animated:YES]; @@ -832,7 +835,7 @@ - (void)showPostFormatSelector SettingsSelectionValuesKey : formats, SettingsSelectionCurrentValueKey : currentDefaultPostFormat }; - + SettingsSelectionViewController *vc = [[SettingsSelectionViewController alloc] initWithDictionary:postFormatsDict]; __weak __typeof__(self) weakSelf = self; vc.onItemSelected = ^(NSString *status) { @@ -840,7 +843,10 @@ - (void)showPostFormatSelector if ([status isKindOfClass:[NSString class]]) { if (weakSelf.blog.settings.defaultPostFormat != status) { weakSelf.blog.settings.defaultPostFormat = status; + if ([weakSelf savingWritingDefaultsIsAvailable]) { + [WPAnalytics trackSettingsChange:@"site_settings" fieldName:@"default_post_format"]; + [weakSelf saveSettings]; } } @@ -1149,6 +1155,9 @@ - (void)postCategoriesViewController:(PostCategoriesViewController *)controller self.blog.settings.defaultCategoryID = category.categoryID; self.defaultCategoryCell.detailTextLabel.text = category.categoryName; if ([self savingWritingDefaultsIsAvailable]) { + [WPAnalytics trackSettingsChange:@"site_settings" + fieldName:@"default_category"]; + [self saveSettings]; } } diff --git a/WordPress/Classes/ViewRelated/Cells/BorderedButtonTableViewCell.swift b/WordPress/Classes/ViewRelated/Cells/BorderedButtonTableViewCell.swift new file mode 100644 index 000000000000..bf8d3f14c758 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Cells/BorderedButtonTableViewCell.swift @@ -0,0 +1,89 @@ +import UIKit + +// UITableViewCell that displays a full width button with a border. +// Properties: +// - normalColor: used for the button label and border. +// - highlightedColor: used for the button label when the button is pressed. +// - buttonInsets: used to provide margins around the button within the cell. +// The delegate is notified when the button is tapped. + +protocol BorderedButtonTableViewCellDelegate: AnyObject { + func buttonTapped() +} + +class BorderedButtonTableViewCell: UITableViewCell { + + // MARK: - Properties + + weak var delegate: BorderedButtonTableViewCellDelegate? + + private var buttonTitle = String() + private var buttonInsets = Defaults.buttonInsets + private var titleFont = Defaults.titleFont + private var normalColor = Defaults.normalColor + private var highlightedColor = Defaults.highlightedColor + + // MARK: - Configure + + func configure(buttonTitle: String, + titleFont: UIFont = Defaults.titleFont, + normalColor: UIColor = Defaults.normalColor, + highlightedColor: UIColor = Defaults.highlightedColor, + buttonInsets: UIEdgeInsets = Defaults.buttonInsets) { + self.buttonTitle = buttonTitle + self.titleFont = titleFont + self.normalColor = normalColor + self.highlightedColor = highlightedColor + self.buttonInsets = buttonInsets + configureView() + } + +} + +// MARK: - Private Extension + +private extension BorderedButtonTableViewCell { + + func configureView() { + selectionStyle = .none + accessibilityTraits = .button + + let button = configuredButton() + contentView.addSubview(button) + contentView.pinSubviewToAllEdges(button, insets: buttonInsets) + } + + func configuredButton() -> UIButton { + let button = UIButton() + let buttonColor = normalColor + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(buttonTitle, for: .normal) + button.setTitleColor(buttonColor, for: .normal) + button.setTitleColor(highlightedColor, for: .highlighted) + button.setBackgroundImage(UIImage.renderBackgroundImage(fill: .clear, border: buttonColor), for: .normal) + button.setBackgroundImage(.renderBackgroundImage(fill: buttonColor, border: buttonColor), for: .highlighted) + + button.titleLabel?.font = titleFont + button.titleLabel?.textAlignment = .center + button.titleLabel?.numberOfLines = 0 + + // Add constraints to the title label, so the button can contain it properly in multi-line cases. + if let label = button.titleLabel { + button.pinSubviewToAllEdgeMargins(label) + } + + button.on(.touchUpInside) { [weak self] _ in + self?.delegate?.buttonTapped() + } + + return button + } + + struct Defaults { + static let buttonInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + static let titleFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + static let normalColor: UIColor = .text + static let highlightedColor: UIColor = .white + } + +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift index 6a8b0cfd2059..1c6acacd24e3 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift @@ -20,6 +20,13 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { var contentLinkTapAction: ((URL) -> Void)? = nil + /// When set to true, the cell will always hide the moderation bar regardless of the user's moderating capabilities. + var hidesModerationBar: Bool = false { + didSet { + updateModerationBarVisibility() + } + } + /// Encapsulate the accessory button image assignment through an enum, to apply a standardized image configuration. /// See `accessoryIconConfiguration` in `WPStyleGuide+CommentDetail`. var accessoryButtonType: AccessoryButtonType = .share { @@ -28,6 +35,18 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { } } + override var indentationWidth: CGFloat { + didSet { + updateContainerLeadingConstraint() + } + } + + override var indentationLevel: Int { + didSet { + updateContainerLeadingConstraint() + } + } + // MARK: Constants private let customBottomSpacing: CGFloat = 10 @@ -37,6 +56,9 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { @IBOutlet private weak var containerStackView: UIStackView! @IBOutlet private weak var containerStackBottomConstraint: NSLayoutConstraint! + // used for indentation + @IBOutlet private weak var containerStackLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var avatarImageView: CircularImageView! @IBOutlet private weak var nameLabel: UILabel! @IBOutlet private weak var dateLabel: UILabel! @@ -45,7 +67,6 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { @IBOutlet private weak var webView: WKWebView! @IBOutlet private weak var webViewHeightConstraint: NSLayoutConstraint! - @IBOutlet private weak var reactionBarView: UIView! @IBOutlet private weak var replyButton: UIButton! @IBOutlet private weak var likeButton: UIButton! @@ -88,10 +109,9 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { // MARK: Visibility Control - /// Controls the visibility of the reaction bar view. Setting this to false disables Reply and Likes functionality. - private var isReactionEnabled: Bool = false { + private var isCommentReplyEnabled: Bool = false { didSet { - reactionBarView.isHidden = !isReactionEnabled + replyButton.isHidden = !isCommentReplyEnabled } } @@ -110,10 +130,14 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { /// Controls the visibility of the moderation bar view. private var isModerationEnabled: Bool = false { didSet { - moderationBar.isHidden = !isModerationEnabled + updateModerationBarVisibility() } } + private var isReactionBarVisible: Bool { + return isCommentReplyEnabled || isCommentLikesEnabled + } + // MARK: Lifecycle override func awakeFromNib() { @@ -141,17 +165,17 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { updateLikeButton(liked: comment.isLiked, numberOfLikes: comment.numberOfLikes()) // Configure feature availability. - isReactionEnabled = !comment.isReadOnly() - isCommentLikesEnabled = isReactionEnabled && (comment.blog?.supports(.commentLikes) ?? false) + isCommentReplyEnabled = comment.canReply() + isCommentLikesEnabled = comment.canLike() isAccessoryButtonEnabled = comment.isApproved() isModerationEnabled = comment.allowsModeration() // When reaction bar is hidden, add some space between the webview and the moderation bar. - containerStackView.setCustomSpacing(isReactionEnabled ? 0 : customBottomSpacing, after: webView) + containerStackView.setCustomSpacing(isReactionBarVisible ? 0 : customBottomSpacing, after: webView) // When both reaction bar and moderation bar is hidden, the custom spacing for the webview won't be applied since it's at the bottom of the stack view. // The reaction bar and the moderation bar have their own spacing, unlike the webview. Therefore, additional bottom spacing is needed. - containerStackBottomConstraint.constant = (isReactionEnabled || isModerationEnabled) ? 0 : customBottomSpacing + containerStackBottomConstraint.constant = (isReactionBarVisible || isModerationEnabled) ? 0 : customBottomSpacing if isModerationEnabled { moderationBar.commentStatus = CommentStatusType.typeForStatus(comment.status) @@ -337,6 +361,14 @@ private extension CommentContentTableViewCell { return htmlContent } + func updateModerationBarVisibility() { + moderationBar.isHidden = !isModerationEnabled || hidesModerationBar + } + + func updateContainerLeadingConstraint() { + containerStackLeadingConstraint?.constant = indentationWidth * CGFloat(indentationLevel) + } + /// Updates the style and text of the Like button. /// - Parameters: /// - liked: Represents the target state – true if the comment is liked, or should be false otherwise. diff --git a/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib index 755d3aa23da4..528dc44b8af3 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib @@ -12,15 +12,15 @@ - - + + - + - - + + @@ -88,11 +88,11 @@ - + diff --git a/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift index 10e5370e2374..f94993934e64 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift @@ -102,40 +102,12 @@ class CommentDetailViewController: UIViewController { return cell }() - private lazy var deleteButton: UIButton = { - let button = UIButton() - let buttonColor = UIColor(light: .error, dark: .muriel(name: .red, .shade40)) - button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle(.deleteButtonText, for: .normal) - button.setTitleColor(buttonColor, for: .normal) - button.setTitleColor(.white, for: .highlighted) - button.setBackgroundImage(UIImage.renderBackgroundImage(fill: .clear, border: buttonColor), for: .normal) - button.setBackgroundImage(.renderBackgroundImage(fill: buttonColor, border: buttonColor), for: .highlighted) - - button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) - button.titleLabel?.textAlignment = .center - button.titleLabel?.numberOfLines = 0 - - // add constraints to the title label, so the button can contain it properly in multi-line cases. - if let label = button.titleLabel { - button.pinSubviewToAllEdgeMargins(label) - } - - button.on(.touchUpInside) { [weak self] _ in - self?.deleteButtonTapped() - } - - return button - }() - - private lazy var deleteButtonCell: UITableViewCell = { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.accessibilityTraits = .button - - cell.contentView.addSubview(deleteButton) - cell.contentView.pinSubviewToAllEdges(deleteButton, insets: Constants.deleteButtonInsets) - + private lazy var deleteButtonCell: BorderedButtonTableViewCell = { + let cell = BorderedButtonTableViewCell() + cell.configure(buttonTitle: .deleteButtonText, + normalColor: UIColor(light: .error, dark: .muriel(name: .red, .shade40)), + buttonInsets: Constants.deleteButtonInsets) + cell.delegate = self return cell }() @@ -264,7 +236,6 @@ private extension CommentDetailViewController { static let tableHorizontalInset: CGFloat = 20.0 static let tableBottomMargin: CGFloat = 40.0 static let replyIndicatorVerticalSpacing: CGFloat = 14.0 - static let deleteButtonInsets = UIEdgeInsets(top: 4, left: 20, bottom: 4, right: 20) } @@ -398,14 +369,12 @@ private extension CommentDetailViewController { func configureHeaderCell() { // if the comment is a reply, show the author of the parent comment. if let parentComment = self.parentComment { - headerCell.textLabel?.text = String(format: .replyCommentTitleFormat, parentComment.authorForDisplay()) - headerCell.detailTextLabel?.text = parentComment.contentPreviewForDisplay().trimmingCharacters(in: .whitespacesAndNewlines) - return + return headerCell.configure(for: .reply(parentComment.authorForDisplay()), + subtitle: parentComment.contentPreviewForDisplay().trimmingCharacters(in: .whitespacesAndNewlines)) } // otherwise, if this is a comment to a post, show the post title instead. - headerCell.textLabel?.text = .postCommentTitleText - headerCell.detailTextLabel?.text = comment.titleForDisplay() + headerCell.configure(for: .post, subtitle: comment.titleForDisplay()) } func configureContentCell(_ cell: CommentContentTableViewCell, comment: Comment) { @@ -631,12 +600,9 @@ private extension String { static let textCellIdentifier = "textCell" // MARK: Localization - static let postCommentTitleText = NSLocalizedString("Comment on", comment: "Provides hint that the current screen displays a comment on a post. " - + "The title of the post will displayed below this string. " - + "Example: Comment on \n My First Post") - static let replyCommentTitleFormat = NSLocalizedString("Reply to %1$@", comment: "Provides hint that the screen displays a reply to a comment." - + "%1$@ is a placeholder for the comment author that's been replied to." - + "Example: Reply to Pamela Nguyen") + static let replyPlaceholderFormat = NSLocalizedString("Reply to %1$@", comment: "Placeholder text for the reply text field." + + "%1$@ is a placeholder for the comment author." + + "Example: Reply to Pamela Nguyen") static let replyIndicatorLabelText = NSLocalizedString("You replied to this comment.", comment: "Informs that the user has replied to this comment.") static let webAddressLabelText = NSLocalizedString("Web address", comment: "Describes the web address section in the comment detail screen.") static let emailAddressLabelText = NSLocalizedString("Email address", comment: "Describes the email address section in the comment detail screen.") @@ -859,7 +825,7 @@ private extension CommentDetailViewController { func configureReplyView() { let replyView = ReplyTextView(width: view.frame.width) - replyView.placeholder = String(format: .replyCommentTitleFormat, comment.authorForDisplay()) + replyView.placeholder = String(format: .replyPlaceholderFormat, comment.authorForDisplay()) replyView.accessibilityIdentifier = NSLocalizedString("Reply Text", comment: "Notifications Reply Accessibility Identifier") replyView.delegate = self replyView.onReply = { [weak self] content in @@ -991,3 +957,13 @@ extension CommentDetailViewController: SuggestionsTableViewDelegate { } } + +// MARK: - BorderedButtonTableViewCellDelegate + +extension CommentDetailViewController: BorderedButtonTableViewCellDelegate { + + func buttonTapped() { + deleteButtonTapped() + } + +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift index 71b3aa2982b0..554924b8b4f8 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift @@ -2,6 +2,29 @@ import UIKit class CommentHeaderTableViewCell: UITableViewCell, Reusable { + enum Title { + /// Title for a top-level comment on a post. + case post + + /// Title for the comment threads. + case thread + + /// Title for a comment that's a reply to another comment. + /// Requires a String describing the replied author's name. + case reply(String) + + var stringValue: String { + switch self { + case .post: + return .postCommentTitleText + case .thread: + return .commentThreadTitleText + case .reply(let author): + return String(format: .replyCommentTitleFormat, author) + } + } + } + // MARK: Initialization required init() { @@ -13,6 +36,18 @@ class CommentHeaderTableViewCell: UITableViewCell, Reusable { fatalError("init(coder:) has not been implemented") } + /// Configures the header cell. + /// - Parameters: + /// - title: The title type for the header. See `Title`. + /// - subtitle: A text snippet of the parent object. + /// - showsDisclosureIndicator: When this is `false`, the cell is configured to look non-interactive. + func configure(for title: Title, subtitle: String, showsDisclosureIndicator: Bool = true) { + textLabel?.setText(title.stringValue) + detailTextLabel?.setText(subtitle) + accessoryType = showsDisclosureIndicator ? .disclosureIndicator : .none + selectionStyle = showsDisclosureIndicator ? .default : .none + } + // MARK: Helpers private typealias Style = WPStyleGuide.CommentDetail.Header @@ -30,3 +65,16 @@ class CommentHeaderTableViewCell: UITableViewCell, Reusable { } } + +// MARK: Localization + +private extension String { + static let postCommentTitleText = NSLocalizedString("Comment on", comment: "Provides hint that the current screen displays a comment on a post. " + + "The title of the post will displayed below this string. " + + "Example: Comment on \n My First Post") + static let replyCommentTitleFormat = NSLocalizedString("Reply to %1$@", comment: "Provides hint that the screen displays a reply to a comment." + + "%1$@ is a placeholder for the comment author that's been replied to." + + "Example: Reply to Pamela Nguyen") + static let commentThreadTitleText = NSLocalizedString("Comments on", comment: "Sentence fragment. " + + "The full phrase is 'Comments on' followed by the title of a post on a separate line.") +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift index 3c15b46ba29e..48275def32e9 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift @@ -465,6 +465,14 @@ class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelega gutenberg.toggleHTMLMode() mode.toggle() editorSession.switch(editor: analyticsEditor) + presentEditingModeSwitchedNotice() + } + + private func presentEditingModeSwitchedNotice() { + let message = mode == .html + ? NSLocalizedString("Switched to HTML mode", comment: "Message of the notice shown when toggling the HTML editor mode") + : NSLocalizedString("Switched to Visual mode", comment: "Message of the notice shown when toggling the Visual editor mode") + gutenberg.showNotice(message) } func requestHTML(for reason: RequestHTMLReason) { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift index 7e1f409a77f5..1acdeff4aaf0 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift @@ -187,6 +187,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func jetpackMonitorEnabledValueChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "monitor_enabled", value: newValue as Any) self.settings.jetpackMonitorEnabled = newValue self.reloadViewModel() self.service.updateJetpackSettingsForBlog(self.blog, @@ -199,6 +200,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func sendNotificationsByEmailValueChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "send_notification_by_email", value: newValue as Any) self.settings.jetpackMonitorEmailNotifications = newValue self.service.updateJetpackMonitorSettingsForBlog(self.blog, success: {}, @@ -210,6 +212,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func sendPushNotificationsValueChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "send_push_notifications", value: newValue as Any) self.settings.jetpackMonitorPushNotifications = newValue self.service.updateJetpackMonitorSettingsForBlog(self.blog, success: {}, @@ -221,6 +224,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func blockMaliciousLoginAttemptsValueChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "block_malicious_logins", value: newValue as Any) self.settings.jetpackBlockMaliciousLoginAttempts = newValue self.reloadViewModel() self.service.updateJetpackSettingsForBlog(self.blog, @@ -268,6 +272,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func ssoEnabledChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "wpcom_login_allowed", value: newValue as Any) self.settings.jetpackSSOEnabled = newValue self.reloadViewModel() self.service.updateJetpackSettingsForBlog(self.blog, @@ -280,6 +285,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func matchAccountsUsingEmailChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "match_accounts_using_email", value: newValue as Any) self.settings.jetpackSSOMatchAccountsByEmail = newValue self.service.updateJetpackSettingsForBlog(self.blog, success: {}, @@ -291,6 +297,7 @@ open class JetpackSettingsViewController: UITableViewController { fileprivate func requireTwoStepAuthenticationChanged() -> (_ newValue: Bool) -> Void { return { [unowned self] newValue in + WPAnalytics.trackSettingsChange("jetpack_settings", fieldName: "require_two_step_auth", value: newValue as Any) self.settings.jetpackSSORequireTwoStepAuthentication = newValue self.service.updateJetpackSettingsForBlog(self.blog, success: {}, diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift index 1d413d7fc5ef..a680335acef7 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift @@ -90,6 +90,8 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { return { [unowned self] newValue in self.settings.jetpackServeImagesFromOurServers = newValue self.reloadViewModel() + WPAnalytics.trackSettingsChange("jetpack_speed_up_site", fieldName: "serve_images", value: newValue as Any) + self.service.updateJetpackServeImagesFromOurServersModuleSettingForBlog(self.blog, success: {}, failure: { [weak self] (_) in @@ -102,6 +104,7 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { return { [unowned self] newValue in self.settings.jetpackLazyLoadImages = newValue self.reloadViewModel() + WPAnalytics.trackSettingsChange("jetpack_speed_up_site", fieldName: "lazy_load_images", value: newValue as Any) self.service.updateJetpackLazyImagesModuleSettingForBlog(self.blog, success: {}, failure: { [weak self] (_) in diff --git a/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift index b51b0c54d71a..364f376b6160 100644 --- a/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift @@ -19,6 +19,10 @@ func AccountSettingsViewController(accountSettingsService: AccountSettingsServic } private class AccountSettingsController: SettingsController { + var trackingKey: String { + return "account_settings" + } + let title = NSLocalizedString("Account Settings", comment: "Account Settings Title") var immuTableRows: [ImmuTableRow.Type] { @@ -91,14 +95,16 @@ private class AccountSettingsController: SettingsController { let editableUsername = EditableTextRow( title: NSLocalizedString("Username", comment: "Account Settings Username label"), value: settings?.username ?? "", - action: presenter.push(changeUsername(with: settings, service: service)) + action: presenter.push(changeUsername(with: settings, service: service)), + fieldName: "username" ) let email = EditableTextRow( title: NSLocalizedString("Email", comment: "Account Settings Email label"), value: settings?.emailForDisplay ?? "", accessoryImage: emailAccessoryImage(), - action: presenter.push(editEmailAddress(settings, service: service)) + action: presenter.push(editEmailAddress(settings, service: service)), + fieldName: "email" ) var primarySiteName = settings.flatMap { service.primarySiteNameForSettings($0) } ?? "" @@ -112,19 +118,22 @@ private class AccountSettingsController: SettingsController { let primarySite = EditableTextRow( title: NSLocalizedString("Primary Site", comment: "Primary Web Site"), value: primarySiteName, - action: presenter.present(insideNavigationController(editPrimarySite(settings, service: service))) + action: presenter.present(insideNavigationController(editPrimarySite(settings, service: service))), + fieldName: "primary_site" ) let webAddress = EditableTextRow( title: NSLocalizedString("Web Address", comment: "Account Settings Web Address label"), value: settings?.webAddress ?? "", - action: presenter.push(editWebAddress(service)) + action: presenter.push(editWebAddress(service)), + fieldName: "web_address" ) let password = EditableTextRow( title: Constants.title, value: "", - action: presenter.push(changePassword(with: settings, service: service)) + action: presenter.push(changePassword(with: settings, service: service)), + fieldName: "password" ) let closeAccount = DestructiveButtonRow( @@ -225,6 +234,8 @@ private class AccountSettingsController: SettingsController { let selectorViewController = BlogSelectorViewController(selectedBlogDotComID: settings?.primarySiteID as NSNumber?, successHandler: { (dotComID: NSNumber?) in if let dotComID = dotComID?.intValue { + WPAnalytics.trackSettingsChange(self.trackingKey, fieldName: "primary_site") + let change = AccountSettingsChange.primarySite(dotComID) service.saveChange(change) } @@ -244,6 +255,8 @@ private class AccountSettingsController: SettingsController { private var closeAccountAction: (ImmuTableRow) -> Void { return { [weak self] _ in guard let self = self else { return } + WPAnalytics.track(.accountCloseTapped, properties: ["has_atomic": self.hasAtomicSite]) + switch self.hasAtomicSite { case true: self.showCloseAccountErrorAlert(message: self.localizedErrorMessageForAtomicSites) @@ -281,10 +294,14 @@ private class AccountSettingsController: SettingsController { guard let self = self else { return } switch $0 { case .success: + WPAnalytics.track(.accountCloseCompleted, properties: ["status": "success"]) let status = NSLocalizedString("Account closed", comment: "Overlay message displayed when account successfully closed") SVProgressHUD.showDismissibleSuccess(withStatus: status) AccountHelper.logOutDefaultWordPressComAccount() case .failure(let error): + let errorCode = self.errorCode(error) ?? "unknown" + WPAnalytics.track(.accountCloseCompleted, properties: ["status": "failure", "error_code": errorCode]) + SVProgressHUD.dismiss() DDLogError("Error closing account: \(error.localizedDescription)") self.showCloseAccountErrorAlert(message: self.generateLocalizedMessage(error)) @@ -306,10 +323,16 @@ private class AccountSettingsController: SettingsController { alert.presentFromRootViewController() } - private func generateLocalizedMessage(_ error: Error) -> String { + private func errorCode(_ error: Error) -> String? { let userInfo = (error as NSError).userInfo let errorCode = userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String + return errorCode + } + + private func generateLocalizedMessage(_ error: Error) -> String { + let errorCode = errorCode(error) + switch errorCode { case "unauthorized": return NSLocalizedString("You're not authorized to close the account.", diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutHeaderView.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutHeaderView.swift new file mode 100644 index 000000000000..ef1a20f2075b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutHeaderView.swift @@ -0,0 +1,208 @@ +import Foundation +import UIKit + + +/// Defines the content of the header that appears on the top level about screen. +struct AboutScreenAppInfo { + /// The app's name + let name: String + /// The current build version of the app + let version: String + /// The app's icon + let icon: UIImage +} + +struct AboutScreenFonts { + let appName: UIFont + let appVersion: UIFont + + static let defaultFonts: AboutScreenFonts = { + // Title is serif semibold large title + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .largeTitle) + let serifFontDescriptor = fontDescriptor.withDesign(.serif) ?? fontDescriptor + let traits = [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold] + let descriptor = serifFontDescriptor.addingAttributes([.traits: traits]) + + let font = UIFont(descriptor: descriptor, size: descriptor.pointSize) + return AboutScreenFonts(appName: font, + appVersion: .preferredFont(forTextStyle: .callout)) + }() +} + +final class AboutHeaderView: UIView { + + // MARK: - Customization Support + + struct Spacing { + let betweenAppIconAndAppNameLabel: CGFloat + let betweenAppNameLabelAndAppVersionLabel: CGFloat + let aboveAndBelowHeaderView: CGFloat + } + + struct Sizing { + let appIconWidthAndHeight: CGFloat + let appIconCornerRadius: CGFloat + } + + // MARK: - Defaults + + public static let defaultSizing = Sizing( + appIconWidthAndHeight: CGFloat(80), + appIconCornerRadius: CGFloat(13)) + + public static let defaultSpacing = Spacing( + betweenAppIconAndAppNameLabel: CGFloat(16), + betweenAppNameLabelAndAppVersionLabel: CGFloat(4), + aboveAndBelowHeaderView: CGFloat(64)) + + // MARK: - View Customization + + private let appInfo: AboutScreenAppInfo + private let spacing: Spacing + private let sizing: Sizing + private let fonts: AboutScreenFonts + private let dismissAction: (() -> Void)? + + // MARK: - Initializers + + init(appInfo: AboutScreenAppInfo, + sizing: Sizing = defaultSizing, + spacing: Spacing = defaultSpacing, + fonts: AboutScreenFonts, + dismissAction: (() -> Void)? = nil) { + + self.appInfo = appInfo + self.sizing = sizing + self.spacing = spacing + self.fonts = fonts + self.dismissAction = dismissAction + + super.init(frame: .zero) + + setupSubviews() + } + + override init(frame: CGRect) { + fatalError("Initializer not implemented!") + } + + required init?(coder: NSCoder) { + fatalError("Initializer not implemented!") + } + + // MARK: - Setting up the subviews + + func setupSubviews() { + let stackView = makeStackView() + let iconView = makeIconView() + let appNameLabel = makeAppNameLabel() + let appVersionLabel = makeAppVersionLabel() + let closeButton = makeCloseButton() + + translatesAutoresizingMaskIntoConstraints = false + clipsToBounds = true + + stackView.addArrangedSubviews([ + iconView, + appNameLabel, + appVersionLabel, + ]) + stackView.setCustomSpacing(spacing.betweenAppIconAndAppNameLabel, after: iconView) + stackView.setCustomSpacing(spacing.betweenAppNameLabelAndAppVersionLabel, after: appNameLabel) + + addSubview(stackView) + addSubview(closeButton) + + NSLayoutConstraint.activate([ + iconView.heightAnchor.constraint(equalToConstant: sizing.appIconWidthAndHeight), + iconView.widthAnchor.constraint(equalToConstant: sizing.appIconWidthAndHeight), + + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.edgeMargin), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.edgeMargin), + stackView.topAnchor.constraint(equalTo: topAnchor, constant: spacing.aboveAndBelowHeaderView), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -spacing.aboveAndBelowHeaderView), + + closeButton.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.closeButtonInset), + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.closeButtonInset), + closeButton.widthAnchor.constraint(equalToConstant: Metrics.closeButtonRadius), + closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor) + ]) + } + + // MARK: - Subviews + + private func makeStackView() -> UIStackView { + let stackView = UIStackView() + + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + } + + private func makeAppNameLabel() -> UILabel { + let appNameLabel = UILabel() + + appNameLabel.text = appInfo.name + appNameLabel.lineBreakMode = .byWordWrapping + appNameLabel.numberOfLines = 1 + appNameLabel.font = fonts.appName + appNameLabel.adjustsFontForContentSizeCategory = true + return appNameLabel + } + + private func makeAppVersionLabel() -> UILabel { + let appVersionLabel = UILabel() + + appVersionLabel.text = appInfo.version + appVersionLabel.textAlignment = .center + appVersionLabel.lineBreakMode = .byWordWrapping + appVersionLabel.numberOfLines = 2 + appVersionLabel.font = fonts.appVersion + appVersionLabel.textColor = .secondaryLabel + appVersionLabel.adjustsFontForContentSizeCategory = true + return appVersionLabel + } + + private func makeIconView() -> UIImageView { + let iconView = UIImageView() + + iconView.image = appInfo.icon + iconView.layer.cornerRadius = sizing.appIconCornerRadius + iconView.layer.masksToBounds = true + return iconView + } + + private func makeCloseButton() -> UIButton { + let closeButton = UIButton() + closeButton.translatesAutoresizingMaskIntoConstraints = false + + let configuration = UIImage.SymbolConfiguration(pointSize: Metrics.closeButtonSymbolSize, weight: .bold) + closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: configuration), for: .normal) + closeButton.tintColor = .secondaryLabel + closeButton.backgroundColor = .quaternarySystemFill + closeButton.layer.cornerRadius = Metrics.closeButtonRadius * 0.5 + closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + + // Hide if we don't have a dismiss action + closeButton.isHidden = (dismissAction == nil) + + return closeButton + } + + + // MARK: - Actions + + @objc private func closeButtonTapped() { + dismissAction?() + } + + // MARK: - Constants + + private enum Metrics { + static let closeButtonRadius: CGFloat = 30 + static let closeButtonInset: CGFloat = 16 + static let closeButtonSymbolSize: CGFloat = 16 + static let edgeMargin: CGFloat = 16.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutScreenConfiguration.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutScreenConfiguration.swift new file mode 100644 index 000000000000..37c27d21b31e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutScreenConfiguration.swift @@ -0,0 +1,88 @@ +import Foundation +import UIKit + +typealias AboutScreenSection = [AboutItem] + +/// Users of AutomatticAboutScreen must provide a configuration conforming to this protocol. +protocol AboutScreenConfiguration { + /// A list of AboutItems, grouped into sections, which will be displayed in the about screen's table view. + var sections: [AboutScreenSection] { get } + + /// A method that dismisses the about screen + func dismissScreen(_ actionContext: AboutItemActionContext) + + /// Called when the screen will be shown for customization purposes and event tracking. + /// + func willShow(viewController: UIViewController) + + /// Called when the screen has been hidden for customization purposes and event tracking. + /// + func didHide(viewController: UIViewController) +} + +typealias AboutItemAction = ((AboutItemActionContext) -> Void) + + +struct AboutItemActionContext { + /// The About Screen view controller itself. + let viewController: UIViewController + + /// If the action was triggered by the user interacting with a specific view, it'll be available here. + let sourceView: UIView? + + init(viewController: UIViewController, sourceView: UIView? = nil) { + self.viewController = viewController + self.sourceView = sourceView + } + + func showSubmenu(title: String = "", configuration: AboutScreenConfiguration) { + let submenu = AutomatticAboutScreen(configuration: configuration, isSubmenu: true) + submenu.title = title + + viewController.navigationController?.pushViewController(submenu, animated: true) + } +} + +/// Defines a single row in the unified about screen. +/// +struct AboutItem { + /// Title displayed in the main textLabel of the item's table row + let title: String + + /// Subtitle displayed in the detailTextLabel of the item's table row + let subtitle: String? + + /// Which cell style should be used to render the item's cell. See `AboutItemCellStyle` for options. + let cellStyle: AboutItemCellStyle + + /// The accessory type that should be used for the item's table row + let accessoryType: UITableViewCell.AccessoryType + + /// If `true`, the item's table row will hide its bottom separator + let hidesSeparator: Bool + + /// An optional action that can be performed when the item's table row is tapped. + /// The action will be passed an `AboutItemActionContext` containing references to the view controller + /// and the source view that triggered the action. + let action: AboutItemAction? + + init(title: String, subtitle: String? = nil, cellStyle: AboutItemCellStyle = .default, accessoryType: UITableViewCell.AccessoryType = .none, hidesSeparator: Bool = false, action: AboutItemAction? = nil) { + self.title = title + self.subtitle = subtitle + self.cellStyle = cellStyle + self.accessoryType = accessoryType + self.hidesSeparator = hidesSeparator + self.action = action + } + + enum AboutItemCellStyle: String { + // Displays only a title + case `default` + // Displays a title on the leading side and a secondary value on the trailing side + case value1 + // Displays a title with a smaller subtitle below + case subtitle + // Displays the custom app logos cell + case appLogos + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutScreenTracker.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutScreenTracker.swift new file mode 100644 index 000000000000..ce47bef9f2e1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutScreenTracker.swift @@ -0,0 +1,59 @@ +import Foundation + +class AboutScreenTracker { + enum Event: String { + case screenShown = "about_screen_shown" + case screenDismissed = "about_screen_dismissed" + case buttonPressed = "about_screen_button_tapped" + + enum Screen: String { + case main + case legalAndMore = "legal_and_more" + } + + enum Button: String, CaseIterable { + case dismiss + case rateUs = "rate_us" + case share + case twitter + case blog + case legal + case automatticFamily = "automattic_family" + case workWithUs = "work_with_us" + + case termsOfService = "terms_of_service" + case privacyPolicy = "privacy_policy" + case sourceCode = "source_code" + case acknowledgements + } + + enum PropertyName: String { + case screen + case button + } + } + + typealias TrackCallback = (String, _ properties: [String: Any]) -> Void + + private let track: TrackCallback + + init(track: @escaping TrackCallback = WPAnalytics.trackString) { + self.track = track + } + + private func track(_ event: Event, properties: [String: Any]) { + track(event.rawValue, properties) + } + + func buttonPressed(_ button: Event.Button, properties: [String: Any]? = nil) { + track(.buttonPressed, properties: properties ?? [Event.PropertyName.button.rawValue: button.rawValue]) + } + + func screenShown(_ screen: Event.Screen) { + track(.screenShown, properties: [Event.PropertyName.screen.rawValue: screen.rawValue]) + } + + func screenDismissed(_ screen: Event.Screen) { + track(.screenDismissed, properties: [Event.PropertyName.screen.rawValue: screen.rawValue]) + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AppAboutScreenConfiguration.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AppAboutScreenConfiguration.swift new file mode 100644 index 000000000000..7a22661502dd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AppAboutScreenConfiguration.swift @@ -0,0 +1,156 @@ +import Foundation +import UIKit +import WordPressShared + + +struct WebViewPresenter { + func present(for url: URL, context: AboutItemActionContext) { + let webViewController = WebViewControllerFactory.controller(url: url) + let navigationController = UINavigationController(rootViewController: webViewController) + context.viewController.present(navigationController, animated: true, completion: nil) + } +} + +class AppAboutScreenConfiguration: AboutScreenConfiguration { + static let appInfo = AboutScreenAppInfo(name: (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) ?? "", + version: Bundle.main.detailedVersionNumber() ?? "", + icon: UIImage(named: AppIcon.currentOrDefault.imageName) ?? UIImage()) + + static let fonts = AboutScreenFonts(appName: WPStyleGuide.serifFontForTextStyle(.largeTitle, fontWeight: .semibold), + appVersion: WPStyleGuide.tableviewTextFont()) + + let sharePresenter: ShareAppContentPresenter + let webViewPresenter = WebViewPresenter() + let tracker = AboutScreenTracker() + + lazy var sections: [[AboutItem]] = { + [ + [ + AboutItem(title: TextContent.rateUs, action: { [weak self] context in + WPAnalytics.track(.appReviewsRatedApp) + self?.tracker.buttonPressed(.rateUs) + AppRatingUtility.shared.ratedCurrentVersion() + UIApplication.shared.open(AppRatingUtility.shared.appReviewUrl) + }), + AboutItem(title: TextContent.share, action: { [weak self] context in + self?.tracker.buttonPressed(.share) + self?.sharePresenter.present(for: .wordpress, in: context.viewController, source: .about, sourceView: context.sourceView) + }), + AboutItem(title: TextContent.twitter, subtitle: "@WordPressiOS", cellStyle: .value1, action: { [weak self] context in + self?.tracker.buttonPressed(.twitter) + self?.webViewPresenter.present(for: Links.twitter, context: context) + }), + AboutItem(title: TextContent.blog, subtitle: "blog.wordpress.com", cellStyle: .value1, action: { [weak self] context in + self?.tracker.buttonPressed(.blog) + self?.webViewPresenter.present(for: Links.blog, context: context) + }) + ], + [ + AboutItem(title: TextContent.legalAndMore, accessoryType: .disclosureIndicator, action: { [weak self] context in + self?.tracker.buttonPressed(.legal) + context.showSubmenu(title: TextContent.legalAndMore, configuration: LegalAndMoreSubmenuConfiguration()) + }), + ], + [ + AboutItem(title: TextContent.automatticFamily, accessoryType: .disclosureIndicator, hidesSeparator: true, action: { [weak self] context in + self?.tracker.buttonPressed(.automatticFamily) + self?.webViewPresenter.present(for: Links.automattic, context: context) + }), + AboutItem(title: "", cellStyle: .appLogos, accessoryType: .none) + ], + [ + AboutItem(title: TextContent.workWithUs, subtitle: TextContent.workWithUsSubtitle, cellStyle: .subtitle, accessoryType: .disclosureIndicator, action: { [weak self] context in + self?.tracker.buttonPressed(.workWithUs) + self?.webViewPresenter.present(for: Links.workWithUs, context: context) + }), + ] + ] + }() + + func dismissScreen(_ actionContext: AboutItemActionContext) { + actionContext.viewController.presentingViewController?.dismiss(animated: true) + } + + func willShow(viewController: UIViewController) { + tracker.screenShown(.main) + } + + func didHide(viewController: UIViewController) { + tracker.screenDismissed(.main) + } + + init(sharePresenter: ShareAppContentPresenter) { + self.sharePresenter = sharePresenter + } + + private enum TextContent { + static let rateUs = NSLocalizedString("Rate Us", comment: "Title for button allowing users to rate the app in the App Store") + static let share = NSLocalizedString("Share with Friends", comment: "Title for button allowing users to share information about the app with friends, such as via Messages") + static let twitter = NSLocalizedString("Twitter", comment: "Title of button that displays the app's Twitter profile") + static let blog = NSLocalizedString("Blog", comment: "Title of a button that displays the WordPress product blog") + static let legalAndMore = NSLocalizedString("Legal and More", comment: "Title of button which shows a list of legal documentation such as privacy policy and acknowledgements") + static let automatticFamily = NSLocalizedString("Automattic Family", comment: "Title of button that displays information about the other apps available from Automattic") + static let workWithUs = NSLocalizedString("Work With Us", comment: "Title of button that displays the Automattic Work With Us web page") + static let workWithUsSubtitle = NSLocalizedString("Join From Anywhere", comment: "Subtitle for button displaying the Automattic Work With Us web page, indicating that Automattic employees can work from anywhere in the world") + } + + private enum Links { + static let twitter = URL(string: "https://twitter.com/WordPressiOS")! + static let blog = URL(string: "https://blog.wordpress.com")! + static let workWithUs = URL(string: "https://automattic.com/work-with-us")! + static let automattic = URL(string: "https://automattic.com")! + } +} + +class LegalAndMoreSubmenuConfiguration: AboutScreenConfiguration { + let webViewPresenter = WebViewPresenter() + let tracker = AboutScreenTracker() + + lazy var sections: [[AboutItem]] = { + [ + [ + linkItem(title: Titles.termsOfService, link: Links.termsOfService, button: .termsOfService), + linkItem(title: Titles.privacyPolicy, link: Links.privacyPolicy, button: .privacyPolicy), + linkItem(title: Titles.sourceCode, link: Links.sourceCode, button: .sourceCode), + linkItem(title: Titles.acknowledgements, link: Links.acknowledgements, button: .acknowledgements), + ] + ] + }() + + private func linkItem(title: String, link: URL, button: AboutScreenTracker.Event.Button) -> AboutItem { + AboutItem(title: title, action: { [weak self] context in + self?.buttonPressed(link: link, context: context, button: button) + }) + } + + private func buttonPressed(link: URL, context: AboutItemActionContext, button: AboutScreenTracker.Event.Button) { + tracker.buttonPressed(button) + webViewPresenter.present(for: link, context: context) + } + + func dismissScreen(_ actionContext: AboutItemActionContext) { + actionContext.viewController.presentingViewController?.dismiss(animated: true) + } + + func willShow(viewController: UIViewController) { + tracker.screenShown(.legalAndMore) + } + + func didHide(viewController: UIViewController) { + tracker.screenDismissed(.legalAndMore) + } + + private enum Titles { + static let termsOfService = NSLocalizedString("Terms of Service", comment: "Title of button that displays the App's terms of service") + static let privacyPolicy = NSLocalizedString("Privacy Policy", comment: "Title of button that displays the App's privacy policy") + static let sourceCode = NSLocalizedString("Source Code", comment: "Title of button that displays the App's source code information") + static let acknowledgements = NSLocalizedString("Acknowledgements", comment: "Title of button that displays the App's acknoledgements") + } + + private enum Links { + static let termsOfService = URL(string: WPAutomatticTermsOfServiceURL)! + static let privacyPolicy = URL(string: WPAutomatticPrivacyURL)! + static let sourceCode = URL(string: WPGithubMainURL)! + static let acknowledgements: URL = URL(string: Bundle.main.url(forResource: "acknowledgements", withExtension: "html")?.absoluteString ?? "")! + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAboutScreen.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAboutScreen.swift new file mode 100644 index 000000000000..7f171b944914 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAboutScreen.swift @@ -0,0 +1,311 @@ +import UIKit + +// Required to prevent rotation in the About screen +private class AutomatticAboutScreenNavigationController: UINavigationController, OrientationLimited { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } +} + +class AutomatticAboutScreen: UIViewController { + private let appInfo: AboutScreenAppInfo? + private let fonts: AboutScreenFonts? + + private let configuration: AboutScreenConfiguration + private let isSubmenu: Bool + + private var sections: [AboutScreenSection] { + configuration.sections + } + + private var appLogosIndexPath: IndexPath? { + for (sectionIndex, row) in sections.enumerated() { + if let rowIndex = row.firstIndex(where: { $0.cellStyle == .appLogos }) { + return IndexPath(row: rowIndex, section: sectionIndex) + } + } + + return nil + } + + // MARK: - Views + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .insetGrouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + + // Occasionally our hidden separator insets can cause the horizontal + // scrollbar to appear on rotation + tableView.showsHorizontalScrollIndicator = false + + if isSubmenu == false { + tableView.tableHeaderView = headerView + tableView.tableFooterView = footerView + } + + tableView.dataSource = self + tableView.delegate = self + + return tableView + }() + + lazy var headerView: UIView? = { + guard let appInfo = appInfo else { + return nil + } + + let headerFonts = fonts ?? AboutScreenFonts.defaultFonts + + let headerView = AboutHeaderView(appInfo: appInfo, fonts: headerFonts, dismissAction: { [weak self] in + self?.dismissAboutScreen() + }) + + // Setting the frame once is needed so that the table view header will show. + // This seems to be a table view bug although I'm not entirely sure. + headerView.frame.size.height = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + + return headerView + }() + + private lazy var footerView: UIView = { + let footerView = UIView() + footerView.backgroundColor = .systemGroupedBackground + + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + footerView.addSubview(containerView) + + let logo = UIImageView(image: UIImage(named: Images.automatticLogo)) + logo.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(logo) + + NSLayoutConstraint.activate([ + containerView.leadingAnchor.constraint(equalTo: footerView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: footerView.trailingAnchor), + containerView.topAnchor.constraint(equalTo: footerView.topAnchor, constant: Metrics.footerVerticalOffset), + containerView.bottomAnchor.constraint(equalTo: footerView.bottomAnchor), + containerView.heightAnchor.constraint(equalToConstant: Metrics.footerHeight), + logo.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + logo.centerYAnchor.constraint(equalTo: containerView.centerYAnchor) + ]) + + footerView.frame.size = footerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + return footerView + }() + + private var shouldShowNavigationBar: Bool { + isSubmenu + } + + // MARK: - View lifecycle + + /// This is the preferred way to create an About screen to present, as a navigation controller is required. + static func controller(appInfo: AboutScreenAppInfo? = nil, configuration: AboutScreenConfiguration, fonts: AboutScreenFonts? = nil, isSubmenu: Bool = false) -> UIViewController { + let viewController = AutomatticAboutScreen(appInfo: appInfo, + configuration: configuration, + fonts: fonts, + isSubmenu: isSubmenu) + let navigationController = AutomatticAboutScreenNavigationController(rootViewController: viewController) + navigationController.modalPresentationStyle = .formSheet + return navigationController + } + + init(appInfo: AboutScreenAppInfo? = nil, configuration: AboutScreenConfiguration, fonts: AboutScreenFonts? = nil, isSubmenu: Bool = false) { + self.appInfo = appInfo + self.fonts = fonts + self.configuration = configuration + self.isSubmenu = isSubmenu + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + if isSubmenu { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissAboutScreen)) + } + + view.backgroundColor = .systemGroupedBackground + + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.safeTopAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + if let headerView = headerView { + headerView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true + } + + updateHeaderSize() + + tableView.reloadData() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + if let indexPath = appLogosIndexPath { + // When rotating (only on iPad), scroll so that the app logos cell is always visible + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.appLogosScrollDelay) { + self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + } + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isMovingFromParent || isBeingDismissedDirectlyOrByAncestor() { + configuration.didHide(viewController: self) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + navigationController?.setNavigationBarHidden(!shouldShowNavigationBar, animated: true) + + if isMovingToParent { + configuration.willShow(viewController: self) + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateHeaderSize() + } + + private func updateHeaderSize() { + guard let headerView = headerView else { + return + } + + headerView.layoutIfNeeded() + + headerView.frame.size.height = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + tableView.tableHeaderView = headerView + } + + // MARK: - Actions + + @objc private func dismissAboutScreen() { + let context = AboutItemActionContext(viewController: self) + configuration.dismissScreen(context) + } + + // MARK: - Constants + + enum Metrics { + static let footerHeight: CGFloat = 58.0 + static let footerVerticalOffset: CGFloat = 20.0 + } + + enum Constants { + static let appLogosScrollDelay: TimeInterval = 0.25 + } + + enum Images { + static let automatticLogo = "automattic-logo" + } +} + +// MARK: - Table view data source + +extension AutomatticAboutScreen: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let section = sections[indexPath.section] + let item = section[indexPath.row] + + let cell = item.makeCell() + + cell.textLabel?.text = item.title + cell.detailTextLabel?.text = item.subtitle + cell.detailTextLabel?.textColor = .secondaryLabel + cell.accessoryType = item.accessoryType + cell.selectionStyle = item.cellSelectionStyle + + return cell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let section = sections[indexPath.section] + let item = section[indexPath.row] + + cell.separatorInset = item.hidesSeparator ? UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .greatestFiniteMagnitude) : tableView.separatorInset + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let section = sections[indexPath.section] + let item = section[indexPath.row] + + return item.cellHeight + } +} + +// MARK: - Table view delegate + +extension AutomatticAboutScreen: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let section = sections[indexPath.section] + let item = section[indexPath.row] + + let context = AboutItemActionContext(viewController: self, sourceView: tableView.cellForRow(at: indexPath)) + + item.action?(context) + + tableView.deselectSelectedRowWithAnimation(true) + } +} + +// MARK: AboutItem Extensions + +private extension AboutItem { + func makeCell() -> UITableViewCell { + switch cellStyle { + case .default: + return UITableViewCell(style: .default, reuseIdentifier: cellStyle.rawValue) + case .value1: + return UITableViewCell(style: .value1, reuseIdentifier: cellStyle.rawValue) + case .subtitle: + return UITableViewCell(style: .subtitle, reuseIdentifier: cellStyle.rawValue) + case .appLogos: + return AutomatticAppLogosCell() + } + } + + var cellHeight: CGFloat { + switch cellStyle { + case .appLogos: + return AutomatticAppLogosCell.Metrics.cellHeight + default: + return UITableView.automaticDimension + } + } + + var cellSelectionStyle: UITableViewCell.SelectionStyle { + switch cellStyle { + case .appLogos: + return .none + default: + return .default + } + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAppLogosCell.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAppLogosCell.swift index 675ad1155e44..9c86d2c90457 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAppLogosCell.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AutomatticAppLogosCell.swift @@ -1,5 +1,6 @@ import UIKit import SpriteKit +import CoreMotion /// A table view cell that contains a SpriteKit game scene which shows logos /// of the various apps from Automattic. @@ -20,6 +21,8 @@ class AutomatticAppLogosCell: UITableViewCell { func commonInit() { spriteKitView = SKView(frame: Metrics.sceneFrame) + spriteKitView.allowsTransparency = true + logosScene = AppLogosScene() // Scene is resized to match the view @@ -82,15 +85,36 @@ private class AppLogosScene: SKScene { // Stores a reference to each of the balls in the scene private var balls: [SKNode] = [] + private let motionManager = CMMotionManager() + private var traitCollection: UITraitCollection? + // Haptics + fileprivate var softGenerator = UIImpactFeedbackGenerator(style: .soft) + fileprivate var rigidGenerator = UIImpactFeedbackGenerator(style: .rigid) + + // Keeps track of the last time a specific physics body made contact. + // Used to limit the number of haptics impacts we trigger as a result of collisions. + fileprivate var contacts: [SKPhysicsBody: TimeInterval] = [:] + + private var bounds: CGRect { + view?.bounds ?? .zero + } // MARK: - Scene lifecycle override func didMove(to view: SKView) { super.didMove(to: view) + motionManager.startAccelerometerUpdates() + generateScene() + + scene?.physicsWorld.contactDelegate = self + } + + deinit { + motionManager.stopAccelerometerUpdates() } override func didChangeSize(_ oldSize: CGSize) { @@ -170,6 +194,7 @@ private class AppLogosScene: SKScene { let physicsBody = SKPhysicsBody(circleOfRadius: Metrics.ballRadius) physicsBody.categoryBitMask = ballCategory physicsBody.collisionBitMask = ballCategory | edgeCategory + physicsBody.contactTestBitMask = ballCategory physicsBody.affectedByGravity = true physicsBody.restitution = Constants.physicsRestitution ball.physicsBody = physicsBody @@ -204,10 +229,6 @@ private class AppLogosScene: SKScene { } } - private var bounds: CGRect { - view?.bounds ?? .zero - } - enum Metrics { static let ballRadius: CGFloat = 36.0 static let logoSize: CGFloat = 40.0 @@ -227,5 +248,56 @@ private class AppLogosScene: SKScene { enum Constants { static let appLogoPrefix = "ua-logo-" static let physicsRestitution: CGFloat = 0.5 + static let phyicsContactDebounce: TimeInterval = 0.25 + static let hapticsImpulseThreshold: TimeInterval = 0.10 + static let gravityModifier: CGFloat = 9.8 + } + + override func update(_ currentTime: TimeInterval) { + if let accelerometerData = motionManager.accelerometerData { + let acceleration = accelerometerData.acceleration + let gravity = gravityVector(with: acceleration) + + physicsWorld.gravity = CGVector(dx: gravity.dx * Constants.gravityModifier, dy: gravity.dy * Constants.gravityModifier) + } + } + + private func gravityVector(with acceleration: CMAcceleration) -> CGVector { + guard UIDevice.current.userInterfaceIdiom == .pad else { + // iPhone locks the interface orientation, so we can just use the acceleration as-is + return CGVector(dx: acceleration.x, dy: acceleration.y) + } + + // iPad rotates the interface so we need to change the gravity acceleration to match + switch UIDevice.current.orientation { + case .portraitUpsideDown: + return CGVector(dx: -acceleration.x, dy: -acceleration.y) + case .landscapeLeft: + return CGVector(dx: -acceleration.y, dy: acceleration.x) + case .landscapeRight: + return CGVector(dx: acceleration.y, dy: -acceleration.x) + default: + return CGVector(dx: acceleration.x, dy: acceleration.y) + } + } +} + +extension AppLogosScene: SKPhysicsContactDelegate { + func didBegin(_ contact: SKPhysicsContact) { + let currentTime = CACurrentMediaTime() + + // If we trigger a haptics impact for every single impact it feels a bit much, + // so we'll ignore concurrent contacts for the same physics body within a small timeout. + if let timestamp = contacts[contact.bodyA], + currentTime - timestamp < Constants.phyicsContactDebounce { + return + } + + // We'll use a soft generator for collisions with a small impulse + // and a rigid generator for harder collisions so we have some variety in the feedback. + let generator: UIImpactFeedbackGenerator = contact.collisionImpulse < Constants.hapticsImpulseThreshold ? softGenerator : rigidGenerator + generator.impactOccurred() + + contacts[contact.bodyA] = currentTime } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/UnifiedAboutHeaderView.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/UnifiedAboutHeaderView.swift deleted file mode 100644 index 37515e44ce78..000000000000 --- a/WordPress/Classes/ViewRelated/Me/App Settings/About/UnifiedAboutHeaderView.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation -import SwiftUI - -final class UnifiedAboutHeaderView: UIView { - - // MARK: - Customization Support - - struct AppInfo { - let icon: UIImage - let name: String - let version: String - } - - struct Spacing { - let betweenAppIconAndAppNameLabel: CGFloat - let betweenAppNameLabelAndAppVersionLabel: CGFloat - let aboveAndBelowHeaderView: CGFloat - } - - struct Sizing { - let appIconWidthAndHeight: CGFloat - let appIconCornerRadius: CGFloat - } - - struct Fonts { - let appName: UIFont - let appVersion: UIFont - } - - // MARK: - Defaults - - public static let defaultSizing = Sizing( - appIconWidthAndHeight: CGFloat(80), - appIconCornerRadius: CGFloat(13)) - - public static let defaultSpacing = Spacing( - betweenAppIconAndAppNameLabel: CGFloat(16), - betweenAppNameLabelAndAppVersionLabel: CGFloat(4), - aboveAndBelowHeaderView: CGFloat(64)) - - // MARK: - View Customization - - private let appInfo: AppInfo - private let spacing: Spacing - private let sizing: Sizing - private let fonts: Fonts - - // MARK: - Initializers - - init(appInfo: AppInfo, - sizing: Sizing = defaultSizing, - spacing: Spacing = defaultSpacing, - fonts: Fonts) { - - self.appInfo = appInfo - self.sizing = sizing - self.spacing = spacing - self.fonts = fonts - - super.init(frame: .zero) - - setupSubviews() - } - - override init(frame: CGRect) { - fatalError("Initializer not implemented!") - } - - required init?(coder: NSCoder) { - fatalError("Initializer not implemented!") - } - - // MARK: - Setting up the subviews - - func setupSubviews() { - let stackView = UIStackView() - let iconView = UIImageView() - let appNameLabel = UILabel() - let appVersionLabel = UILabel() - - clipsToBounds = true - - appNameLabel.text = appInfo.name - appNameLabel.lineBreakMode = .byWordWrapping - appNameLabel.numberOfLines = 1 - appNameLabel.font = fonts.appName - - appVersionLabel.text = appInfo.version - appVersionLabel.lineBreakMode = .byWordWrapping - appVersionLabel.numberOfLines = 1 - appVersionLabel.font = fonts.appVersion - appVersionLabel.textColor = .secondaryLabel - - iconView.image = appInfo.icon - iconView.layer.cornerRadius = sizing.appIconCornerRadius - iconView.layer.masksToBounds = true - - stackView.axis = .vertical - stackView.alignment = .center - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubviews([ - iconView, - appNameLabel, - appVersionLabel, - ]) - stackView.setCustomSpacing(spacing.betweenAppIconAndAppNameLabel, after: iconView) - stackView.setCustomSpacing(spacing.betweenAppNameLabelAndAppVersionLabel, after: appNameLabel) - - addSubview(stackView) - - NSLayoutConstraint.activate([ - iconView.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), - iconView.heightAnchor.constraint(equalToConstant: sizing.appIconWidthAndHeight), - iconView.widthAnchor.constraint(equalToConstant: sizing.appIconWidthAndHeight), - - appNameLabel.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), - appVersionLabel.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), - - stackView.centerXAnchor.constraint(equalTo: centerXAnchor), - stackView.topAnchor.constraint(equalTo: topAnchor, constant: spacing.aboveAndBelowHeaderView), - stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -spacing.aboveAndBelowHeaderView), - ]) - } -} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/UnifiedAboutViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/UnifiedAboutViewController.swift deleted file mode 100644 index 88da32457302..000000000000 --- a/WordPress/Classes/ViewRelated/Me/App Settings/About/UnifiedAboutViewController.swift +++ /dev/null @@ -1,218 +0,0 @@ -import UIKit -import WordPressShared - -/// Defines a single row in the unified about screen. -/// -struct AboutItem { - let title: String - let subtitle: String? - let cellStyle: AboutItemCellStyle - let action: (() -> Void)? - - init(title: String, subtitle: String? = nil, cellStyle: AboutItemCellStyle = .default, action: (() -> Void)? = nil) { - self.title = title - self.subtitle = subtitle - self.cellStyle = cellStyle - self.action = action - } - - func makeCell() -> UITableViewCell { - switch cellStyle { - case .default: - return UITableViewCell(style: .default, reuseIdentifier: cellStyle.rawValue) - case .value1: - return UITableViewCell(style: .value1, reuseIdentifier: cellStyle.rawValue) - case .subtitle: - return UITableViewCell(style: .subtitle, reuseIdentifier: cellStyle.rawValue) - case .appLogos: - return AutomatticAppLogosCell() - } - } - - var cellHeight: CGFloat { - switch cellStyle { - case .appLogos: - return AutomatticAppLogosCell.Metrics.cellHeight - default: - return UITableView.automaticDimension - } - } - - var cellAccessoryType: UITableViewCell.AccessoryType { - switch cellStyle { - case .appLogos: - return .none - default: - return .disclosureIndicator - } - } - - var cellSelectionStyle: UITableViewCell.SelectionStyle { - switch cellStyle { - case .appLogos: - return .none - default: - return .default - } - } - - enum AboutItemCellStyle: String { - // Displays only a title - case `default` - // Displays a title on the leading side and a secondary value on the trailing side - case value1 - // Displays a title with a smaller subtitle below - case subtitle - // Displays the custom app logos cell - case appLogos - } -} - -class UnifiedAboutViewController: UIViewController { - static let sections: [[AboutItem]] = [ - [ - AboutItem(title: "Rate Us"), - AboutItem(title: "Share with Friends"), - AboutItem(title: "Twitter", cellStyle: .value1) - ], - [ - AboutItem(title: "Legal and More") - ], - [ - AboutItem(title: "Automattic Family"), - AboutItem(title: "", cellStyle: .appLogos) - ], - [ - AboutItem(title: "Work With Us", subtitle: "Join From Anywhere", cellStyle: .subtitle) - ] - ] - - let headerView: UIView = { - // These customizations are temporarily here, but if this VC is moved into a framework we'll need to move them - // into the main App. - let appInfo = UnifiedAboutHeaderView.AppInfo( - icon: UIImage(named: AppIcon.currentOrDefault.imageName) ?? UIImage(), - name: (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) ?? "", - version: Bundle.main.detailedVersionNumber() ?? "") - - let fonts = UnifiedAboutHeaderView.Fonts( - appName: WPStyleGuide.serifFontForTextStyle(.largeTitle, fontWeight: .semibold), - appVersion: WPStyleGuide.tableviewTextFont()) - - let headerView = UnifiedAboutHeaderView(appInfo: appInfo, fonts: fonts) - - // Setting the frame once is needed so that the table view header will show. - // This seems to be a table view bug although I'm not entirely sure. - headerView.frame.size = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - - return headerView - }() - - // MARK: - Views - - private lazy var tableView: UITableView = { - let tableView = UITableView(frame: .zero, style: .insetGrouped) - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.tableHeaderView = headerView - - tableView.dataSource = self - tableView.delegate = self - - return tableView - }() - - private lazy var footerView: UIView = { - let footerView = UIView() - footerView.translatesAutoresizingMaskIntoConstraints = false - footerView.backgroundColor = .systemGroupedBackground - - let logo = UIImageView(image: UIImage(named: Images.automatticLogo)) - logo.translatesAutoresizingMaskIntoConstraints = false - footerView.addSubview(logo) - - NSLayoutConstraint.activate([ - logo.centerXAnchor.constraint(equalTo: footerView.centerXAnchor), - logo.centerYAnchor.constraint(equalTo: footerView.centerYAnchor) - ]) - - return footerView - }() - - // MARK: - View lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemGroupedBackground - - view.addSubview(tableView) - view.addSubview(footerView) - - NSLayoutConstraint.activate([ - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor), - footerView.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: Metrics.footerVerticalOffset), - footerView.heightAnchor.constraint(equalToConstant: Metrics.footerHeight), - footerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - footerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - - tableView.reloadData() - } - - // MARK: - Constants - - enum Metrics { - static let footerHeight: CGFloat = 58.0 - static let footerVerticalOffset: CGFloat = 20.0 - } - - enum Images { - static let automatticLogo = "automattic-logo" - } -} - -// MARK: - Table view data source - -extension UnifiedAboutViewController: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - return Self.sections.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Self.sections[section].count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section = Self.sections[indexPath.section] - let row = section[indexPath.row] - - let cell = row.makeCell() - - cell.textLabel?.text = row.title - cell.detailTextLabel?.text = row.subtitle - cell.accessoryType = row.cellAccessoryType - cell.selectionStyle = row.cellSelectionStyle - - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let section = Self.sections[indexPath.section] - let row = section[indexPath.row] - - return row.cellHeight - } -} - -// MARK: - Table view delegate - -extension UnifiedAboutViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let section = Self.sections[indexPath.section] - let row = section[indexPath.row] - row.action?() - } -} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift index da5d5743398f..c773a2b81f17 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift @@ -114,8 +114,11 @@ open class AppIconViewController: UITableViewController { UIApplication.shared.setAlternateIconName(iconName, completionHandler: { [weak self] error in if error == nil { - let event: WPAnalyticsStat = isOriginalIcon ? .appIconReset : .appIconChanged - WPAppAnalytics.track(event) + if isOriginalIcon { + WPAppAnalytics.track(.appIconReset) + } else { + WPAppAnalytics.track(.appIconChanged, withProperties: ["icon_name": iconName ?? "default"]) + } } self?.tableView.reloadData() diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift index 9c06246cceb1..894f77d27a03 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift @@ -133,6 +133,8 @@ class AppSettingsViewController: UITableViewController { } fileprivate func clearMediaCache() { + WPAnalytics.track(.appSettingsClearMediaCacheTapped) + setMediaCacheRowDescription(status: .clearingCache) MediaFileManager.clearAllMediaCacheFiles(onCompletion: { [weak self] in self?.updateMediaCacheSize() @@ -148,13 +150,20 @@ class AppSettingsViewController: UITableViewController { MediaSettings().maxImageSizeSetting = value ShareExtensionService.configureShareExtensionMaximumMediaDimension(value) - var properties = [String: AnyObject]() - properties["enabled"] = (value != Int.max) as AnyObject - properties["value"] = value as Int as AnyObject - WPAnalytics.track(.appSettingsImageOptimizationChanged, withProperties: properties) + self.debounce(#selector(self.trackImageSizeChanged), afterDelay: 0.5) } } + @objc func trackImageSizeChanged() { + let value = MediaSettings().maxImageSizeSetting + + var properties = [String: AnyObject]() + properties["enabled"] = (value != Int.max) as AnyObject + properties["value"] = value as Int as AnyObject + + WPAnalytics.track(.appSettingsImageOptimizationChanged, withProperties: properties) + } + func pushVideoResolutionSettings() -> ImmuTableAction { return { [weak self] row in let values = [MediaSettings.VideoResolution.size640x480, @@ -260,6 +269,8 @@ class AppSettingsViewController: UITableViewController { func openPrivacySettings() -> ImmuTableAction { return { [weak self] _ in + WPAnalytics.track(.privacySettingsOpened) + let controller = PrivacySettingsViewController() self?.navigationController?.pushViewController(controller, animated: true) } @@ -267,6 +278,8 @@ class AppSettingsViewController: UITableViewController { func openApplicationSettings() -> ImmuTableAction { return { [weak self] row in + WPAnalytics.track(.appSettingsOpenDeviceSettingsTapped) + if let targetURL = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(targetURL) @@ -280,19 +293,23 @@ class AppSettingsViewController: UITableViewController { func clearSiriActivityDonations() -> ImmuTableAction { return { [tableView] _ in + WPAnalytics.track(.appSettingsClearSiriSuggestionsTapped) + tableView?.deselectSelectedRowWithAnimation(true) if #available(iOS 12.0, *) { NSUserActivity.deleteAllSavedUserActivities {} } - let notice = Notice(title: NSLocalizedString("Siri Reset Confirmation", comment: "Notice displayed to the user after clearing the Siri activity donations."), feedbackType: .success) + let notice = Notice(title: NSLocalizedString("Siri Reset Confirmation", value: "Successfully cleared Siri Shortcut Suggestions", comment: "Notice displayed to the user after clearing the Siri activity donations."), feedbackType: .success) ActionDispatcher.dispatch(NoticeAction.post(notice)) } } func clearSpotlightCache() -> ImmuTableAction { return { [weak self] row in + WPAnalytics.track(.appSettingsClearSpotlightIndexTapped) + self?.tableView.deselectSelectedRowWithAnimation(true) SearchManager.shared.deleteAllSearchableItems() let notice = Notice(title: NSLocalizedString("Successfully cleared spotlight index", comment: "Notice displayed to the user after clearing the spotlight index in app settings."), @@ -428,7 +445,7 @@ private extension AppSettingsViewController { if #available(iOS 12.0, *) { let siriClearCacheRow = DestructiveButtonRow( - title: NSLocalizedString("Siri Reset Prompt", comment: "Label for button that clears user activities donated to Siri."), + title: NSLocalizedString("Siri Reset Prompt", value: "Clear Siri Shortcut Suggestions", comment: "Label for button that clears user activities donated to Siri."), action: clearSiriActivityDonations(), accessibilityIdentifier: "spotlightClearCacheButton") diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift index c87f5d485a03..fc03124b9aba 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift @@ -160,6 +160,9 @@ class PrivacySettingsViewController: UITableViewController { func crashReportingChanged(_ enabled: Bool) { UserSettings.userHasOptedOutOfCrashLogging = !enabled + + WPAnalytics.track(.privacySettingsReportCrashesToggled, properties: ["enabled": enabled]) + WordPressAppDelegate.crashLogging?.setNeedsDataRefresh() } } diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift index bede85cdef81..24aaaecf6c04 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift @@ -253,8 +253,10 @@ class MeViewController: UITableViewController { private func pushAbout() -> ImmuTableAction { return { [unowned self] _ in - let controller = UnifiedAboutViewController() - controller.modalPresentationStyle = .formSheet + let configuration = AppAboutScreenConfiguration(sharePresenter: self.sharePresenter) + let controller = AutomatticAboutScreen.controller(appInfo: AppAboutScreenConfiguration.appInfo, + configuration: configuration, + fonts: AppAboutScreenConfiguration.fonts) self.present(controller, animated: true) { self.tableView.deselectSelectedRowWithAnimation(true) } diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift b/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift index 81788e7bc213..9ba28cd105b9 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift @@ -44,6 +44,9 @@ private func makeHeaderView(account: WPAccount) -> MyProfileHeaderView { /// To avoid problems, it's marked private and should only be initialized using the /// `MyProfileViewController` factory functions. private class MyProfileController: SettingsController { + var trackingKey: String { + return "my_profile" + } // MARK: - Private Properties @@ -111,24 +114,28 @@ private class MyProfileController: SettingsController { let firstNameRow = EditableTextRow( title: NSLocalizedString("First Name", comment: "My Profile first name label"), value: settings?.firstName ?? "", - action: presenter.push(editText(AccountSettingsChange.firstName, service: service))) + action: presenter.push(editText(AccountSettingsChange.firstName, service: service)), + fieldName: "first_name") let lastNameRow = EditableTextRow( title: NSLocalizedString("Last Name", comment: "My Profile last name label"), value: settings?.lastName ?? "", - action: presenter.push(editText(AccountSettingsChange.lastName, service: service))) + action: presenter.push(editText(AccountSettingsChange.lastName, service: service)), + fieldName: "last_name") let displayNameRow = EditableTextRow( title: NSLocalizedString("Display Name", comment: "My Profile display name label"), value: settings?.displayName ?? "", - action: presenter.push(editText(AccountSettingsChange.displayName, service: service))) + action: presenter.push(editText(AccountSettingsChange.displayName, service: service)), + fieldName: "display_name") let aboutMeRow = EditableTextRow( title: NSLocalizedString("About Me", comment: "My Profile 'About me' label"), value: settings?.aboutMe ?? "", action: presenter.push(editMultilineText(AccountSettingsChange.aboutMe, hint: NSLocalizedString("Tell us a bit about you.", comment: "My Profile 'About me' hint text"), - service: service))) + service: service)), + fieldName: "about_me") return ImmuTable(sections: [ ImmuTableSection(rows: [ diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard b/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard index 4201064267bc..93c3f7cbf5bd 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard @@ -17,18 +17,18 @@ - + - + - + - + @@ -59,7 +59,7 @@ - + @@ -67,10 +67,10 @@ - + - + @@ -78,13 +78,13 @@ - + - + @@ -94,7 +94,8 @@ - + + @@ -110,7 +111,7 @@ - + diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueChooseSiteTableViewCell.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueChooseSiteTableViewCell.swift new file mode 100644 index 000000000000..8849559f7e7a --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueChooseSiteTableViewCell.swift @@ -0,0 +1,61 @@ +import UIKit + +final class LoginEpilogueChooseSiteTableViewCell: UITableViewCell { + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let stackView = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Private Methods +private extension LoginEpilogueChooseSiteTableViewCell { + func setupViews() { + backgroundColor = .basicBackground + selectionStyle = .none + setupTitleLabel() + setupSubtitleLabel() + setupStackView() + } + + func setupTitleLabel() { + titleLabel.text = NSLocalizedString("Choose a site to open.", comment: "A text for title label on Login epilogue screen") + titleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .medium) + } + + func setupSubtitleLabel() { + subtitleLabel.text = NSLocalizedString("You can switch sites at any time.", comment: "A text for subtitle label on Login epilogue screen") + subtitleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + subtitleLabel.textColor = .secondaryLabel + } + + func setupStackView() { + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = Constants.stackViewSpacing + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubviews([titleLabel, subtitleLabel]) + contentView.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.stackViewHorizontalMargin), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.stackViewHorizontalMargin), + stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Constants.stackViewTopMargin), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Constants.stackViewBottomMargin) + ]) + } + + private enum Constants { + static let stackViewSpacing: CGFloat = 4.0 + static let stackViewHorizontalMargin: CGFloat = 20.0 + static let stackViewTopMargin: CGFloat = 16.0 + static let stackViewBottomMargin: CGFloat = 26.0 + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueConnectSiteCell.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueConnectSiteCell.swift deleted file mode 100644 index d98060c0baf2..000000000000 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueConnectSiteCell.swift +++ /dev/null @@ -1,26 +0,0 @@ -import UIKit - -class LoginEpilogueConnectSiteCell: UITableViewCell, NibReusable { - - // Properties - - @IBOutlet var connectLabel: UILabel! - - // Init - - func configure(numberOfSites: Int) { - connectLabel.text = numberOfSites == 0 ? LocalizedText.connectSite : LocalizedText.connectAnother - connectLabel.font = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, weight: .regular) - connectLabel.textColor = .primary - accessibilityIdentifier = "connectSite" - accessibilityTraits = .button - } - -} - -private extension LoginEpilogueConnectSiteCell { - enum LocalizedText { - static let connectSite = NSLocalizedString("Connect a site", comment: "Link to connect a site, shown after logging in.") - static let connectAnother = NSLocalizedString("Connect another site", comment: "Link to connect another site, shown after logging in.") - } -} diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueConnectSiteCell.xib b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueConnectSiteCell.xib deleted file mode 100644 index 4821e52f6a7d..000000000000 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueConnectSiteCell.xib +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueCreateNewSiteCell.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueCreateNewSiteCell.swift new file mode 100644 index 000000000000..4d3e37abed83 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueCreateNewSiteCell.swift @@ -0,0 +1,69 @@ +import UIKit +import WordPressUI + +protocol LoginEpilogueCreateNewSiteCellDelegate: AnyObject { + func didTapCreateNewSite() +} + +final class LoginEpilogueCreateNewSiteCell: UITableViewCell { + private let dividerView = LoginEpilogueDividerView() + private let createNewSiteButton = FancyButton() + weak var delegate: LoginEpilogueCreateNewSiteCellDelegate? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Private Methods +private extension LoginEpilogueCreateNewSiteCell { + func setupViews() { + selectionStyle = .none + setupDividerView() + setupCreateNewSiteButton() + } + + func setupDividerView() { + dividerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(dividerView) + NSLayoutConstraint.activate([ + dividerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + dividerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + dividerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Constants.dividerViewTopMargin), + dividerView.heightAnchor.constraint(equalToConstant: Constants.dividerViewHeight) + ]) + } + + func setupCreateNewSiteButton() { + createNewSiteButton.setTitle(NSLocalizedString("Create a new site", comment: "A button title"), for: .normal) + createNewSiteButton.accessibilityIdentifier = "Create a new site" + createNewSiteButton.isPrimary = false + createNewSiteButton.addTarget(self, action: #selector(didTapCreateNewSiteButton), for: .touchUpInside) + createNewSiteButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(createNewSiteButton) + NSLayoutConstraint.activate([ + createNewSiteButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.createNewSiteButtonHorizontalMargin), + createNewSiteButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.createNewSiteButtonHorizontalMargin), + createNewSiteButton.topAnchor.constraint(equalTo: dividerView.bottomAnchor), + createNewSiteButton.heightAnchor.constraint(equalToConstant: Constants.createNewSiteButtonHeight), + createNewSiteButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + @objc func didTapCreateNewSiteButton() { + delegate?.didTapCreateNewSite() + } + + private enum Constants { + static let dividerViewTopMargin: CGFloat = 20.0 + static let dividerViewHeight: CGFloat = 48.0 + static let createNewSiteButtonHorizontalMargin: CGFloat = 20.0 + static let createNewSiteButtonHeight: CGFloat = 44.0 + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueDividerView.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueDividerView.swift new file mode 100644 index 000000000000..2d9fb97f015e --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueDividerView.swift @@ -0,0 +1,63 @@ +import UIKit +import WordPressAuthenticator + +final class LoginEpilogueDividerView: UIView { + private let leadingDividerLine = UIView() + private let trailingDividerLine = UIView() + private let dividerLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Private Methods +private extension LoginEpilogueDividerView { + func setupViews() { + setupTitleLabel() + setupLeadingDividerLine() + setupTrailingDividerLine() + } + + func setupTitleLabel() { + dividerLabel.textColor = .divider + dividerLabel.font = .preferredFont(forTextStyle: .footnote) + dividerLabel.text = NSLocalizedString("Or", comment: "Divider on initial auth view separating auth options.").localizedUppercase + dividerLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(dividerLabel) + NSLayoutConstraint.activate([ + dividerLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + dividerLabel.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func setupLeadingDividerLine() { + leadingDividerLine.backgroundColor = .divider + leadingDividerLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(leadingDividerLine) + NSLayoutConstraint.activate([ + leadingDividerLine.centerYAnchor.constraint(equalTo: dividerLabel.centerYAnchor), + leadingDividerLine.leadingAnchor.constraint(equalTo: leadingAnchor), + leadingDividerLine.trailingAnchor.constraint(equalTo: dividerLabel.leadingAnchor, constant: -4), + leadingDividerLine.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth) + ]) + } + + func setupTrailingDividerLine() { + trailingDividerLine.backgroundColor = .divider + trailingDividerLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(trailingDividerLine) + NSLayoutConstraint.activate([ + trailingDividerLine.centerYAnchor.constraint(equalTo: dividerLabel.centerYAnchor), + trailingDividerLine.leadingAnchor.constraint(equalTo: dividerLabel.trailingAnchor, constant: 4), + trailingDividerLine.trailingAnchor.constraint(equalTo: trailingAnchor), + trailingDividerLine.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth) + ]) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueTableViewController.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueTableViewController.swift index 2267755f8130..57d00e18db52 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueTableViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueTableViewController.swift @@ -23,13 +23,9 @@ class LoginEpilogueTableViewController: UITableViewController { /// private var credentials: AuthenticatorCredentials? - /// Closure to be executed when Connect Site is selected. + /// Flag indicating if the Create A New Site button should be displayed. /// - private var onConnectSite: (() -> Void)? - - /// Flag indicating if the Connect Site option should be displayed. - /// - private var showConnectSite: Bool { + private var showCreateNewSite: Bool { guard AppConfiguration.allowsConnectSite else { return false } @@ -45,37 +41,28 @@ class LoginEpilogueTableViewController: UITableViewController { AuthenticatorAnalyticsTracker.shared } - // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() - let headerNib = UINib(nibName: "EpilogueSectionHeaderFooter", bundle: nil) - tableView.register(headerNib, forHeaderFooterViewReuseIdentifier: Settings.headerReuseIdentifier) - let userInfoNib = UINib(nibName: "EpilogueUserInfoCell", bundle: nil) tableView.register(userInfoNib, forCellReuseIdentifier: Settings.userCellReuseIdentifier) - - tableView.register(LoginEpilogueConnectSiteCell.defaultNib, - forCellReuseIdentifier: LoginEpilogueConnectSiteCell.defaultReuseID) - - // Remove separator line on last row - tableView.tableFooterView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: 1))) - - // To facilitate the button blur effect, the table is extended under the button view. - // So the last cells can be seen when scrolled, move the content up above the button view. - tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 100, right: 0) - + tableView.register(LoginEpilogueChooseSiteTableViewCell.self, forCellReuseIdentifier: Settings.chooseSiteReuseIdentifier) + tableView.register(LoginEpilogueCreateNewSiteCell.self, forCellReuseIdentifier: Settings.createNewSiteReuseIdentifier) view.backgroundColor = .basicBackground tableView.backgroundColor = .basicBackground + tableView.rowHeight = UITableView.automaticDimension + tableView.accessibilityIdentifier = "login-epilogue-table" + + // Remove separator line on last row + tableView.tableFooterView = UIView() } /// Initializes the EpilogueTableView so that data associated with the specified Endpoint is displayed. /// - func setup(with credentials: AuthenticatorCredentials, onConnectSite: (() -> Void)? = nil) { + func setup(with credentials: AuthenticatorCredentials) { self.credentials = credentials - self.onConnectSite = onConnectSite refreshInterface(for: credentials) } } @@ -97,8 +84,8 @@ extension LoginEpilogueTableViewController { } } - // Add one for Connect Site if there are no sites from blogDataSource. - if adjustedNumberOfSections == 0 && showConnectSite { + // Add one for Create A New Site if there are no sites from blogDataSource. + if adjustedNumberOfSections == 0 && showCreateNewSite { adjustedNumberOfSections += 1 } @@ -108,40 +95,69 @@ extension LoginEpilogueTableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == Sections.userInfoSection { - return 1 + return 2 } let correctedSection = section - 1 let siteRows = blogDataSource.tableView(tableView, numberOfRowsInSection: correctedSection) - // Add one for the Connect Site row if shown. - return showConnectSite ? siteRows + 1 : siteRows + // Add one for Create new site cell + + guard let parent = parent as? LoginEpilogueViewController else { + return siteRows + } + + if siteRows <= Constants.createNewSiteRowThreshold { + parent.hideButtonPanel() + return showCreateNewSite ? siteRows + 1 : siteRows + } else { + if !showCreateNewSite { + parent.hideButtonPanel() + } + return siteRows + } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // User Info Row if indexPath.section == Sections.userInfoSection { - guard let cell = tableView.dequeueReusableCell(withIdentifier: Settings.userCellReuseIdentifier) as? EpilogueUserInfoCell else { - return UITableViewCell() - } - if let info = epilogueUserInfo { - cell.stopSpinner() - cell.configure(userInfo: info) - } else { - cell.startSpinner() - } + if indexPath.row == 0 { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Settings.userCellReuseIdentifier) as? EpilogueUserInfoCell else { + return UITableViewCell() + } + removeSeparatorFor(cell) + if let info = epilogueUserInfo { + cell.stopSpinner() + cell.configure(userInfo: info) + } else { + cell.startSpinner() + } - return cell + return cell + } else if indexPath.row == 1 { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Settings.chooseSiteReuseIdentifier, for: indexPath) as? LoginEpilogueChooseSiteTableViewCell else { + return UITableViewCell() + } + removeSeparatorFor(cell) + return cell + } } - // Connect Site Row - if indexPath.row == lastRowInSection(indexPath.section) && showConnectSite { - guard let cell = tableView.dequeueReusableCell(withIdentifier: LoginEpilogueConnectSiteCell.defaultReuseID) as? LoginEpilogueConnectSiteCell else { + // Create new site row + let siteRows = blogDataSource.tableView(tableView, numberOfRowsInSection: indexPath.section - 1) + + let isCreateNewSiteRow = + showCreateNewSite && + siteRows <= Constants.createNewSiteRowThreshold && + indexPath.row == lastRowInSection(indexPath.section) + + if isCreateNewSiteRow { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Settings.createNewSiteReuseIdentifier, for: indexPath) as? LoginEpilogueCreateNewSiteCell else { return UITableViewCell() } - - cell.configure(numberOfSites: numberOfWordPressComBlogs) + cell.delegate = self + removeSeparatorFor(cell) return cell } @@ -152,112 +168,59 @@ extension LoginEpilogueTableViewController { guard let loginCell = cell as? LoginEpilogueBlogCell else { return cell } - + if indexPath.row == lastRowInSection(indexPath.section) { + removeSeparatorFor(cell) + } loginCell.adjustSiteNameConstraint() return loginCell } - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - - // Don't show section header for User Info - guard section != Sections.userInfoSection, - let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: Settings.headerReuseIdentifier) as? EpilogueSectionHeaderFooter else { - return nil - } - - // Don't show section header if there are no sites. - guard rowCount(forSection: section) > 0 else { - return nil - } - - cell.titleLabel?.text = title(for: section) - - cell.accessibilityIdentifier = "siteListHeaderCell" - cell.accessibilityLabel = cell.titleLabel?.text - cell.contentView.backgroundColor = .basicBackground - cell.accessibilityHint = NSLocalizedString("A list of sites on this account.", comment: "Accessibility hint for My Sites list.") - - return cell - } - override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return indexPath.section == Sections.userInfoSection ? Settings.profileRowHeight : Settings.blogRowHeight } - override func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { - return Settings.headerHeight - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return false } - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - - if section == Sections.userInfoSection { - return 0 + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let parent = parent as? LoginEpilogueViewController, + tableView.cellForRow(at: indexPath) is LoginEpilogueBlogCell else { + return } - if rowCount(forSection: section) == 0 { - tableView.separatorStyle = .none - return 0 - } + let wrappedPath = IndexPath(row: indexPath.row, section: indexPath.section - 1) + let blog = blogDataSource.blog(at: wrappedPath) - return UITableView.automaticDimension + parent.blogSelected(blog) } - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return false + private enum Constants { + static let createNewSiteRowThreshold = 3 } +} - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard showConnectSite, - indexPath.section != Sections.userInfoSection, - indexPath.row == lastRowInSection(indexPath.section) else { +// MARK: - LoginEpilogueCreateNewSiteCellDelegate +extension LoginEpilogueTableViewController: LoginEpilogueCreateNewSiteCellDelegate { + func didTapCreateNewSite() { + guard let parent = parent as? LoginEpilogueViewController else { return } - - tracker.track(click: .connectSite) - tracker.set(flow: .loginWithSiteAddress) - onConnectSite?() + parent.createNewSite() } } // MARK: - Private Extension // private extension LoginEpilogueTableViewController { - - /// Returns the title for a given section. - /// - func title(for section: Int) -> String? { - guard section != Sections.userInfoSection else { - return nil - } - - if rowCount(forSection: section) > 1 { - return NSLocalizedString("My Sites", comment: "Header for list of multiple sites, shown after logging in").localizedUppercase - } - - return NSLocalizedString("My Site", comment: "Header for a single site, shown after logging in").localizedUppercase - } - /// Returns the last row index for a given section. /// func lastRowInSection(_ section: Int) -> Int { return (tableView.numberOfRows(inSection: section) - 1) } - /// Returns the number of WordPress.com sites. - /// - var numberOfWordPressComBlogs: Int { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - - return service.defaultWordPressComAccount()?.blogs.count ?? 0 - } - - func rowCount(forSection section: Int) -> Int { - return blogDataSource.tableView(tableView, numberOfRowsInSection: section - 1) + func removeSeparatorFor(_ cell: UITableViewCell) { + cell.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .greatestFiniteMagnitude) } enum Sections { @@ -267,6 +230,8 @@ private extension LoginEpilogueTableViewController { enum Settings { static let headerReuseIdentifier = "SectionHeader" static let userCellReuseIdentifier = "userInfo" + static let chooseSiteReuseIdentifier = "chooseSite" + static let createNewSiteReuseIdentifier = "createNewSite" static let profileRowHeight = CGFloat(180) static let blogRowHeight = CGFloat(60) static let headerHeight = CGFloat(50) diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift index b7dd891efc81..4d674935580d 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift @@ -17,14 +17,16 @@ class LoginEpilogueViewController: UIViewController { @IBOutlet var topLine: UIView! @IBOutlet var topLineHeightConstraint: NSLayoutConstraint! - /// Done Button. + /// Create a new site button. /// - @IBOutlet var doneButton: UIButton! + @IBOutlet var createANewSiteButton: UIButton! /// Constraints on the table view container. /// Used to adjust the width on iPad. @IBOutlet var tableViewLeadingConstraint: NSLayoutConstraint! @IBOutlet var tableViewTrailingConstraint: NSLayoutConstraint! + @IBOutlet weak var tableViewBottomContraint: NSLayoutConstraint! + private var defaultTableViewMargin: CGFloat = 0 /// Blur effect on button panel @@ -33,6 +35,8 @@ class LoginEpilogueViewController: UIViewController { return .systemChromeMaterial } + private var dividerView: LoginEpilogueDividerView? + /// Links to the Epilogue TableViewController /// private var tableViewController: LoginEpilogueTableViewController? @@ -41,9 +45,13 @@ class LoginEpilogueViewController: UIViewController { /// private let tracker = AuthenticatorAnalyticsTracker.shared - /// Closure to be executed upon dismissal. + /// Closure to be executed upon blog selection. + /// + var onBlogSelected: ((Blog) -> Void)? + + /// Closure to be executed upon a new site creation. /// - var onDismiss: (() -> Void)? + var onCreateNewSite: (() -> Void)? /// Site that was just connected to our awesome app. /// @@ -94,10 +102,7 @@ class LoginEpilogueViewController: UIViewController { fatalError() } - epilogueTableViewController.setup(with: credentials, onConnectSite: { [weak self] in - self?.handleConnectAnotherButton() - }) - + epilogueTableViewController.setup(with: credentials) tableViewController = epilogueTableViewController } @@ -107,7 +112,7 @@ class LoginEpilogueViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - configurePanelBasedOnTableViewContents() + configureButtonPanel() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -120,6 +125,23 @@ class LoginEpilogueViewController: UIViewController { setTableViewMargins(forWidth: view.frame.width) } + func hideButtonPanel() { + buttonPanel.isHidden = true + createANewSiteButton.isHidden = true + tableViewBottomContraint.constant = 0 + } + + // MARK: - Actions + + func createNewSite() { + onCreateNewSite?() + WPAnalytics.track(.loginEpilogueCreateNewSiteTapped) + } + + func blogSelected(_ blog: Blog) { + onBlogSelected?(blog) + WPAnalytics.track(.loginEpilogueChooseSiteTapped, properties: [:], blog: blog) + } } // MARK: - Private Extension @@ -129,38 +151,25 @@ private extension LoginEpilogueViewController { /// Refreshes the UI so that the specified WordPressSite is displayed. /// func refreshInterface(with credentials: AuthenticatorCredentials) { - configureDoneButton() + configureCreateANewSiteButton() } /// Setup: Buttons /// - func configureDoneButton() { - doneButton.setTitle(NSLocalizedString("Done", comment: "A button title"), for: .normal) - doneButton.accessibilityIdentifier = "Done" + func configureCreateANewSiteButton() { + createANewSiteButton.setTitle(NSLocalizedString("Create a new site", comment: "A button title"), for: .normal) + createANewSiteButton.accessibilityIdentifier = "Create a new site" } /// Setup: Button Panel /// - func configurePanelBasedOnTableViewContents() { - guard let tableView = tableViewController?.tableView else { - return - } - + func configureButtonPanel() { topLineHeightConstraint.constant = .hairlineBorderWidth - - let contentSize = tableView.contentSize - let screenHeight = UIScreen.main.bounds.height - let panelHeight = buttonPanel.frame.height - - if contentSize.height >= (screenHeight - panelHeight) { - topLine.isHidden = false - blurEffectView.effect = UIBlurEffect(style: blurEffect) - blurEffectView.isHidden = false - } else { - buttonPanel.backgroundColor = .basicBackground - topLine.isHidden = true - blurEffectView.isHidden = true - } + buttonPanel.backgroundColor = .quaternaryBackground + topLine.isHidden = false + blurEffectView.effect = UIBlurEffect(style: blurEffect) + blurEffectView.isHidden = false + setupDividerLineIfNeeded() } func setTableViewMargins(forWidth viewWidth: CGFloat) { @@ -181,24 +190,32 @@ private extension LoginEpilogueViewController { tableViewTrailingConstraint.constant = margin } + func setupDividerLineIfNeeded() { + guard dividerView == nil else { return } + dividerView = LoginEpilogueDividerView() + guard let dividerView = dividerView else { return } + dividerView.translatesAutoresizingMaskIntoConstraints = false + buttonPanel.addSubview(dividerView) + NSLayoutConstraint.activate([ + dividerView.leadingAnchor.constraint(equalTo: buttonPanel.leadingAnchor), + dividerView.trailingAnchor.constraint(equalTo: buttonPanel.trailingAnchor), + dividerView.topAnchor.constraint(equalTo: buttonPanel.topAnchor), + dividerView.heightAnchor.constraint(equalToConstant: Constants.dividerViewHeight) + ]) + } + enum TableViewMarginMultipliers { static let ipadPortrait: CGFloat = 0.1667 static let ipadLandscape: CGFloat = 0.25 } - // MARK: - Actions - - @IBAction func dismissEpilogue() { - tracker.track(click: .continue) - onDismiss?() - navigationController?.dismiss(animated: true) + private enum Constants { + static let dividerViewHeight: CGFloat = 40.0 } - func handleConnectAnotherButton() { - guard let controller = WordPressAuthenticator.signinForWPOrg() else { - return - } + // MARK: - Actions - navigationController?.setViewControllers([controller], animated: true) + @IBAction func createANewSite() { + createNewSite() } } diff --git a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialCoordinator.swift b/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialCoordinator.swift deleted file mode 100644 index f44063157eaa..000000000000 --- a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialCoordinator.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -private struct Constants { - static let userDefaultsKeyFormat = "PostSignUpInterstitial.hasSeenBefore.%@" -} - -class PostSignUpInterstitialCoordinator { - private let database: KeyValueDatabase - private let userId: NSNumber? - - init(database: KeyValueDatabase = UserDefaults.standard, userId: NSNumber? = nil ) { - self.database = database - - self.userId = userId ?? { - let context = ContextManager.sharedInstance().mainContext - let acctServ = AccountService(managedObjectContext: context) - let account = acctServ.defaultWordPressComAccount() - - return account?.userID - }() - } - - /// Generates the user defaults key for the logged in user - /// Returns nil if we can not get the default WP.com account - private var userDefaultsKey: String? { - get { - guard let userId = self.userId else { - return nil - } - - return String(format: Constants.userDefaultsKeyFormat, userId) - } - } - - /// Determines whether or not the PSI should be displayed for the logged in user - /// - Parameters: - /// - numberOfBlogs: The number of blogs the account has - @objc func shouldDisplay(numberOfBlogs: Int) -> Bool { - if hasSeenBefore() { - return false - } - - return numberOfBlogs == 0 - } - - /// Determines whether the PSI has been displayed to the logged in user - func hasSeenBefore() -> Bool { - guard let key = userDefaultsKey else { - return false - } - - return database.bool(forKey: key) - } - - /// Marks the PSI as seen for the logged in user - func markAsSeen() { - guard let key = userDefaultsKey else { - return - } - - database.set(true, forKey: key) - } -} diff --git a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.swift b/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.swift index 521e214e202c..f33135f7ed49 100644 --- a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.swift @@ -68,9 +68,6 @@ class PostSignUpInterstitialViewController: UIViewController { configureI18N() - let coordinator = PostSignUpInterstitialCoordinator() - coordinator.markAsSeen() - WPAnalytics.track(.welcomeNoSitesInterstitialShown) } @@ -125,10 +122,7 @@ class PostSignUpInterstitialViewController: UIViewController { return false } - let numberOfBlogs = self.numberOfBlogs() - - let coordinator = PostSignUpInterstitialCoordinator() - return coordinator.shouldDisplay(numberOfBlogs: numberOfBlogs) + return self.numberOfBlogs() == 0 } private class func numberOfBlogs() -> Int { diff --git a/WordPress/Classes/ViewRelated/NUX/WordPressAuthenticationManager.swift b/WordPress/Classes/ViewRelated/NUX/WordPressAuthenticationManager.swift index fe7653b35d20..c9e13e7d2c51 100644 --- a/WordPress/Classes/ViewRelated/NUX/WordPressAuthenticationManager.swift +++ b/WordPress/Classes/ViewRelated/NUX/WordPressAuthenticationManager.swift @@ -1,6 +1,7 @@ import Foundation import WordPressAuthenticator import Gridicons +import UIKit // MARK: - WordPressAuthenticationManager @@ -14,9 +15,18 @@ class WordPressAuthenticationManager: NSObject { /// without having to reimplement WordPressAuthenticatorDelegate private let authenticationHandler: AuthenticationHandler? - init(windowManager: WindowManager, authenticationHandler: AuthenticationHandler? = nil) { + private let quickStartSettings: QuickStartSettings + + private let recentSiteService: RecentSitesService + + init(windowManager: WindowManager, + authenticationHandler: AuthenticationHandler? = nil, + quickStartSettings: QuickStartSettings = QuickStartSettings(), + recentSiteService: RecentSitesService = RecentSitesService()) { self.windowManager = windowManager self.authenticationHandler = authenticationHandler + self.quickStartSettings = quickStartSettings + self.recentSiteService = recentSiteService } /// Support is only available to the WordPress iOS App. Our Authentication Framework doesn't have direct access. @@ -47,7 +57,7 @@ extension WordPressAuthenticationManager { private func authenticatorConfiguation() -> WordPressAuthenticatorConfiguration { // SIWA can not be enabled for internal builds // Ref https://github.com/wordpress-mobile/WordPress-iOS/pull/12332#issuecomment-521994963 - let enableSignInWithApple = AppConfiguration.allowSignUp && !(BuildConfiguration.current ~= [.a8cBranchTest, .a8cPrereleaseTesting]) + let enableSignInWithApple = !(BuildConfiguration.current ~= [.a8cBranchTest, .a8cPrereleaseTesting]) return WordPressAuthenticatorConfiguration(wpcomClientId: ApiCredentials.client, wpcomSecret: ApiCredentials.secret, @@ -318,6 +328,17 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { return } + let onDismissQuickStartPrompt: (Blog, Bool) -> Void = { [weak self] blog, _ in + self?.onDismissQuickStartPrompt(for: blog, onDismiss: onDismiss) + } + + // If adding a self-hosted site, skip the Epilogue + if let wporg = credentials.wporg, + let blog = Blog.lookup(username: wporg.username, xmlrpc: wporg.xmlrpc, in: ContextManager.shared.mainContext) { + presentQuickStartPrompt(for: blog, in: navigationController, onDismiss: onDismissQuickStartPrompt) + return + } + if PostSignUpInterstitialViewController.shouldDisplay() { self.presentPostSignUpInterstitial(in: navigationController, onDismiss: onDismiss) return @@ -330,10 +351,33 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { } epilogueViewController.credentials = credentials - epilogueViewController.onDismiss = { [weak self] in - onDismiss() - self?.windowManager.dismissFullscreenSignIn() + epilogueViewController.onBlogSelected = { [weak self] blog in + guard let self = self else { + return + } + + self.recentSiteService.touch(blog: blog) + + guard self.quickStartSettings.isQuickStartAvailable(for: blog) else { + if self.windowManager.isShowingFullscreenSignIn { + self.windowManager.dismissFullscreenSignIn(blogToShow: blog) + } else { + self.windowManager.showAppUI(for: blog) + } + return + } + + self.presentQuickStartPrompt(for: blog, in: navigationController, onDismiss: onDismissQuickStartPrompt) + } + + epilogueViewController.onCreateNewSite = { + let wizardLauncher = SiteCreationWizardLauncher(onDismiss: onDismissQuickStartPrompt) + guard let wizard = wizardLauncher.ui else { + return + } + + navigationController.present(wizard, animated: true) } navigationController.pushViewController(epilogueViewController, animated: true) @@ -391,7 +435,6 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { return true } - /// Whenever a WordPress.com account has been created during the Auth flow, we'll add a new local WPCOM Account, and set it as /// the new DefaultWordPressComAccount. /// @@ -464,6 +507,46 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { } } +// MARK: - Quick Start Prompt +private extension WordPressAuthenticationManager { + func presentQuickStartPrompt(for blog: Blog, in navigationController: UINavigationController, onDismiss: ((Blog, Bool) -> Void)?) { + // If the quick start prompt has already been dismissed, + // then show the My Site screen for the specified blog + guard !quickStartSettings.promptWasDismissed(for: blog) else { + + if self.windowManager.isShowingFullscreenSignIn { + self.windowManager.dismissFullscreenSignIn(blogToShow: blog) + } else { + navigationController.dismiss(animated: true) + } + + return + } + + // Otherwise, show the Quick Start prompt + let quickstartPrompt = QuickStartPromptViewController(blog: blog) + quickstartPrompt.onDismiss = onDismiss + navigationController.pushViewController(quickstartPrompt, animated: true) + } + + func onDismissQuickStartPrompt(for blog: Blog, onDismiss: @escaping () -> Void) { + onDismiss() + + // If the quick start prompt has already been dismissed, + // then show the My Site screen for the specified blog + guard !self.quickStartSettings.promptWasDismissed(for: blog) else { + self.windowManager.dismissFullscreenSignIn(blogToShow: blog) + return + } + + // Otherwise, show the My Site screen for the specified blog and after a short delay, + // trigger the Quick Start tour + self.windowManager.showAppUI(for: blog, completion: { + QuickStartTourGuide.shared.setupWithDelay(for: blog) + }) + } +} + // MARK: - WordPressAuthenticatorManager // diff --git a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift index 504d971fd6b1..9e490ea53004 100644 --- a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift @@ -271,7 +271,6 @@ import Gridicons textView.contentInset = .zero textView.textContainerInset = .zero textView.autocorrectionType = .yes - textView.backgroundColor = Style.backgroundColor textView.textColor = Style.textColor textView.textContainer.lineFragmentPadding = 0 textView.layoutManager.allowsNonContiguousLayout = false diff --git a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib index 0787185f4880..50cfdbe8522b 100644 --- a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib +++ b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib @@ -4,7 +4,6 @@ - @@ -56,7 +55,6 @@ - @@ -69,30 +67,27 @@ - - - - + + + - + - @@ -114,7 +109,6 @@ - @@ -142,8 +136,5 @@ - - - diff --git a/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift b/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift index 3e5ab11bcae7..fa533c56f1e0 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift @@ -875,6 +875,7 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe controller.addActionWithTitle(setHomepageButtonTitle, style: .default, handler: { [weak self] _ in if let pageID = page.postID?.intValue { self?.beginRefreshingManually() + WPAnalytics.track(.postListSetHomePageAction) self?.homepageSettingsService?.setHomepageType(.page, homePageID: pageID, success: { self?.refreshAndReload() @@ -907,6 +908,7 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe controller.addActionWithTitle(setPostsPageButtonTitle, style: .default, handler: { [weak self] _ in if let pageID = page.postID?.intValue { self?.beginRefreshingManually() + WPAnalytics.track(.postListSetAsPostsPageAction) self?.homepageSettingsService?.setHomepageType(.page, withPostsPageID: pageID, success: { self?.refreshAndReload() diff --git a/WordPress/Classes/ViewRelated/People/PeopleViewController.swift b/WordPress/Classes/ViewRelated/People/PeopleViewController.swift index a23a70665765..e052ef98ea50 100644 --- a/WordPress/Classes/ViewRelated/People/PeopleViewController.swift +++ b/WordPress/Classes/ViewRelated/People/PeopleViewController.swift @@ -169,7 +169,12 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { super.viewWillAppear(animated) tableView.deselectSelectedRowWithAnimation(true) refreshNoResultsView() - WPAnalytics.track(.openedPeople) + + guard let blog = blog else { + return + } + + WPAppAnalytics.track(.openedPeople, with: blog) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { diff --git a/WordPress/Classes/ViewRelated/Post/PostListViewController.swift b/WordPress/Classes/ViewRelated/Post/PostListViewController.swift index 03861628337d..9f2e526bef16 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListViewController.swift @@ -732,6 +732,8 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe return } + WPAnalytics.track(.postListShareAction, properties: propertiesForAnalytics()) + let shareController = PostSharingController() shareController.sharePost(post, fromView: view, inViewController: self) } diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift index 5ed89780ce52..ab76864ddeb6 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift @@ -123,6 +123,9 @@ private struct DateAndTimeRow: ImmuTableRow { } @objc class PublishSettingsController: NSObject, SettingsController { + var trackingKey: String { + return "publish_settings" + } @objc class func viewController(post: AbstractPost) -> ImmuTableViewController { let controller = PublishSettingsController(post: post) diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift index 07306065a102..b43d95aa1cd2 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -60,6 +60,10 @@ class ReaderDetailCoordinator { /// Post Service private let postService: PostService + /// Comment Service + private let commentService: CommentService + private let commentsDisplayed: UInt = 2 + /// Used for `RequestAuthenticator` creation and likes filtering logic. private let accountService: AccountService @@ -106,6 +110,7 @@ class ReaderDetailCoordinator { readerPostService: ReaderPostService = ReaderPostService(managedObjectContext: ContextManager.sharedInstance().mainContext), topicService: ReaderTopicService = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext), postService: PostService = PostService(managedObjectContext: ContextManager.sharedInstance().mainContext), + commentService: CommentService = CommentService(managedObjectContext: ContextManager.sharedInstance().mainContext), accountService: AccountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext), sharingController: PostSharingController = PostSharingController(), readerLinkRouter: UniversalLinkRouter = UniversalLinkRouter(routes: UniversalLinkRouter.readerRoutes), @@ -114,6 +119,7 @@ class ReaderDetailCoordinator { self.readerPostService = readerPostService self.topicService = topicService self.postService = postService + self.commentService = commentService self.accountService = accountService self.sharingController = sharingController self.readerLinkRouter = readerLinkRouter @@ -185,6 +191,28 @@ class ReaderDetailCoordinator { }) } + /// Fetch Comments for the current post. + /// + func fetchComments(for post: ReaderPost) { + commentService.syncHierarchicalComments(for: post, + topLevelComments: commentsDisplayed, + success: { [weak self] _, totalComments in + self?.updateCommentsFor(post: post, totalComments: totalComments?.intValue ?? 0) + }, failure: { [weak self] error in + DDLogError("Failed fetching post detail comments: \(String(describing: error))") + self?.view?.updateComments([], totalComments: 0) + }) + } + + func updateCommentsFor(post: ReaderPost, totalComments: Int) { + guard let comments = commentService.topLevelComments(commentsDisplayed, for: post) as? [Comment] else { + view?.updateComments([], totalComments: 0) + return + } + + view?.updateComments(comments, totalComments: totalComments) + } + /// Share the current post /// func share(fromView anchorView: UIView) { diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard index 0ce1e5bc903e..ae90b5beee53 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard @@ -1,9 +1,9 @@ - + - + @@ -43,6 +43,14 @@ + + + + + + + + @@ -97,14 +105,17 @@ + + - + + @@ -129,7 +140,7 @@ - + @@ -160,14 +171,14 @@ - + @@ -237,28 +248,28 @@ - + - +